feat: add desktop-ui frontend
This commit is contained in:
38
desktop-ui/.gitignore
vendored
Normal file
38
desktop-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
22
desktop-ui/index.html
Normal file
22
desktop-ui/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<style>
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
8
desktop-ui/jsconfig.json
Normal file
8
desktop-ui/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
3020
desktop-ui/package-lock.json
generated
Normal file
3020
desktop-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
desktop-ui/package.json
Normal file
25
desktop-ui/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "desktop-ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"element-plus": "^2.13.7",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
desktop-ui/public/favicon.ico
Normal file
BIN
desktop-ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
346
desktop-ui/src/App.vue
Normal file
346
desktop-ui/src/App.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
import { Setting, SwitchButton, Odometer, Collection, Timer, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { ref, computed, getCurrentInstance } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Login from './views/Login.vue'
|
||||
|
||||
const { $http } = getCurrentInstance().appContext.config.globalProperties
|
||||
|
||||
const nickname = ref(localStorage.getItem('nickname'))
|
||||
const role = ref(localStorage.getItem('role'))
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
|
||||
// 用户管理
|
||||
const userDialogVisible = ref(false)
|
||||
const users = ref([])
|
||||
const userForm = ref({ username: '', nickname: '', password: '' })
|
||||
const userFormVisible = ref(false)
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userid')
|
||||
localStorage.removeItem('nickname')
|
||||
localStorage.removeItem('role')
|
||||
role.value = null
|
||||
nickname.value = ''
|
||||
ElMessage.success('已退出登录')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
async function openUserManager() {
|
||||
await loadUsers()
|
||||
userDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await $http.get('/user')
|
||||
users.value = res.data || []
|
||||
} catch {
|
||||
users.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateUser() {
|
||||
userForm.value = { username: '', nickname: '', password: '' }
|
||||
userFormVisible.value = true
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
if (!userForm.value.username || !userForm.value.password) {
|
||||
ElMessage.error('用户名和密码不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await $http.post('/user', userForm.value)
|
||||
ElMessage.success('创建成功')
|
||||
userFormVisible.value = false
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除用户"${row.username}"吗?`, '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await $http.delete(`/user/${row._id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await loadUsers()
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 nickname/role(Login.vue 登录成功后调用)
|
||||
window.__updateUserInfo = (info) => {
|
||||
nickname.value = info.nickname
|
||||
role.value = info.role
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout-container" style="height: 100vh">
|
||||
<el-aside width="200px" class="side-bar">
|
||||
<div class="logo-area">
|
||||
<span class="logo-icon">🔬</span>
|
||||
<span class="logo-text">LabManager</span>
|
||||
</div>
|
||||
<el-menu :default-active="$route.path" router class="side-menu" :collapse-transition="false">
|
||||
<el-menu-item index="/">
|
||||
<el-icon>
|
||||
<Odometer />
|
||||
</el-icon>
|
||||
<span>看板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/standard">
|
||||
<el-icon>
|
||||
<Collection />
|
||||
</el-icon>
|
||||
<span>对照品</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/stability">
|
||||
<el-icon>
|
||||
<Timer />
|
||||
</el-icon>
|
||||
<span>稳定性</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/about">
|
||||
<el-icon>
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
<span>关于</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="top-bar">
|
||||
<div class="header-left">
|
||||
<span class="page-label">{{ $route.meta?.title || '' }}</span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" class="user-avatar">{{ nickname?.charAt(0) || '?' }}</el-avatar>
|
||||
<span class="user-name">{{ nickname }}</span>
|
||||
</div>
|
||||
<el-dropdown trigger="click">
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="isAdmin" @click="openUserManager">
|
||||
<el-icon :size="16">
|
||||
<Setting />
|
||||
</el-icon>
|
||||
用户管理
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="logout">
|
||||
<el-icon :size="16">
|
||||
<SwitchButton />
|
||||
</el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
<el-button class="setting-btn" circle :icon="Setting" size="small" />
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main-area">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<Login />
|
||||
|
||||
<!-- 用户管理对话框 -->
|
||||
<el-dialog v-model="userDialogVisible" title="用户管理" width="600px" align-center>
|
||||
<div class="user-mgr-toolbar">
|
||||
<span class="user-count">共 {{ users.length }} 个用户</span>
|
||||
<el-button type="primary" size="small" @click="openCreateUser">新增用户</el-button>
|
||||
</div>
|
||||
<el-table :data="users" stripe size="small">
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="nickname" label="昵称" />
|
||||
<el-table-column prop="role" label="角色" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small">
|
||||
{{ row.role === 'admin' ? '管理员' : '用户' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" link @click="deleteUser(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增用户对话框 -->
|
||||
<el-dialog v-model="userFormVisible" title="新增用户" width="420px" align-center>
|
||||
<el-form :model="userForm" label-position="top">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="userForm.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="userForm.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" required>
|
||||
<el-input v-model="userForm.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="userFormVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="createUser">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
font-family:
|
||||
Inter, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* ===== 整体布局 ===== */
|
||||
.layout-container {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/* ===== 侧边栏 ===== */
|
||||
.side-bar {
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 22px 20px 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #e0e6f0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
flex: 1;
|
||||
border-right: none !important;
|
||||
background: transparent !important;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.side-menu .el-menu-item {
|
||||
color: rgba(224, 230, 240, 0.65);
|
||||
font-size: 14px;
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
margin: 2px 10px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.side-menu .el-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e0e6f0;
|
||||
}
|
||||
|
||||
.side-menu .el-menu-item.is-active {
|
||||
background: linear-gradient(135deg, #4f8cff 0%, #6c5ce7 100%);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(79, 140, 255, 0.35);
|
||||
}
|
||||
|
||||
.side-menu .el-menu-item .el-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ===== 顶栏 ===== */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px !important;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8ecf1;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
padding: 0 24px !important;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2a3a;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #4f8cff, #6c5ce7);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2a3a;
|
||||
}
|
||||
|
||||
.setting-btn {
|
||||
--el-bg-color: transparent;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
color: #8896a8;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.setting-btn:hover {
|
||||
background: #f0f2f5;
|
||||
color: #4f8cff;
|
||||
}
|
||||
|
||||
/* ===== 主内容区 ===== */
|
||||
.main-area {
|
||||
padding: 0 !important;
|
||||
background: #f5f7fa;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
31
desktop-ui/src/main.js
Normal file
31
desktop-ui/src/main.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import axios from 'axios'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: 'https://solidaim.cn/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
app.config.globalProperties.$http = http
|
||||
|
||||
app.mount('#app')
|
||||
37
desktop-ui/src/router/index.js
Normal file
37
desktop-ui/src/router/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import Standard from "../views/Standard.vue";
|
||||
import Stability from "../views/Stability.vue";
|
||||
import AboutView from "../views/AboutView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
meta: { title: "仪表板" },
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/standard",
|
||||
name: "standard",
|
||||
meta: { title: "对照品管理" },
|
||||
component: Standard,
|
||||
},
|
||||
{
|
||||
path: "/stability",
|
||||
name: "stability",
|
||||
meta: { title: "稳定性实验管理" },
|
||||
component: Stability,
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
name: "about",
|
||||
meta: { title: "关于" },
|
||||
component: AboutView,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
180
desktop-ui/src/views/AboutView.vue
Normal file
180
desktop-ui/src/views/AboutView.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<div class="hero">
|
||||
<div class="hero-icon">🔬</div>
|
||||
<h1 class="hero-title">LabManager</h1>
|
||||
<p class="hero-desc">实验室对照品与稳定性实验管理系统</p>
|
||||
<div class="hero-badges">
|
||||
<el-tag>v1.0.0</el-tag>
|
||||
<el-tag type="success">MIT License</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="24" class="content-row">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<span class="card-title">📖 项目介绍</span>
|
||||
</template>
|
||||
<p>LabManager 是一款面向药品与化工实验室的轻量级管理工具,旨在帮助实验室高效管理对照品(标准物质)和稳定性实验的全流程。</p>
|
||||
<p>系统提供了对照品的入库、存储位置追踪、有效期预警管理,以及稳定性实验的批次规划、检查点跟踪、样品取出确认等功能,让实验室数据管理更加规范、可追溯。</p>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<span class="card-title">⚙️ 技术栈</span>
|
||||
</template>
|
||||
<div class="tech-stack">
|
||||
<div class="tech-group">
|
||||
<h4>前端</h4>
|
||||
<div class="tech-tags">
|
||||
<el-tag>Vue 3</el-tag>
|
||||
<el-tag>Vite</el-tag>
|
||||
<el-tag>Element Plus</el-tag>
|
||||
<el-tag>Axios</el-tag>
|
||||
<el-tag>Vue Router</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tech-group">
|
||||
<h4>后端</h4>
|
||||
<div class="tech-tags">
|
||||
<el-tag>Node.js</el-tag>
|
||||
<el-tag>Express</el-tag>
|
||||
<el-tag>MongoDB</el-tag>
|
||||
<el-tag>Mongoose</el-tag>
|
||||
<el-tag>JWT</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<span class="card-title">📋 功能列表</span>
|
||||
</template>
|
||||
<el-timeline>
|
||||
<el-timeline-item timestamp="看板" placement="top">
|
||||
数据概览与快捷入口
|
||||
</el-timeline-item>
|
||||
<el-timeline-item timestamp="对照品管理" placement="top">
|
||||
批号 / 含量 / 纯度 / 有效期 / 存放位置 CRUD
|
||||
</el-timeline-item>
|
||||
<el-timeline-item timestamp="稳定性实验" placement="top">
|
||||
批次规划 / 检查点跟踪 / 样品取出确认
|
||||
</el-timeline-item>
|
||||
<el-timeline-item timestamp="用户管理" placement="top">
|
||||
管理员用户创建与权限控制
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<span class="card-title">👤 关于作者</span>
|
||||
</template>
|
||||
<p class="author-line">本项目由实验室管理团队开发和维护。</p>
|
||||
<p class="author-line">如有问题或建议,欢迎联系管理员。</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.about-page {
|
||||
padding: 32px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ===== Hero 区域 ===== */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 40px 0 36px;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
font-size: 56px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1f2a3a;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
color: #8896a8;
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== 内容卡片 ===== */
|
||||
.content-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e8ecf1;
|
||||
}
|
||||
|
||||
.info-card :deep(.el-card__header) {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2a3a;
|
||||
}
|
||||
|
||||
.info-card p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #3d4a5c;
|
||||
}
|
||||
|
||||
.info-card p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ===== 技术栈 ===== */
|
||||
.tech-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tech-group h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #8896a8;
|
||||
}
|
||||
|
||||
.tech-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ===== 作者 ===== */
|
||||
.author-line {
|
||||
margin: 0 0 4px !important;
|
||||
}
|
||||
</style>
|
||||
191
desktop-ui/src/views/HomeView.vue
Normal file
191
desktop-ui/src/views/HomeView.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const { $http } = getCurrentInstance().appContext.config.globalProperties
|
||||
const router = useRouter()
|
||||
|
||||
const standardCount = ref(0)
|
||||
const stabilityCount = ref(0)
|
||||
const pendingChecks = ref(0)
|
||||
const latestStandards = ref([])
|
||||
const latestStabilities = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (!localStorage.getItem('token')) return
|
||||
try {
|
||||
const [stdRes, stabRes] = await Promise.all([$http.get('/standard'), $http.get('/stability')])
|
||||
const standards = stdRes.data || []
|
||||
const stabilities = stabRes.data || []
|
||||
standardCount.value = standards.length
|
||||
stabilityCount.value = stabilities.length
|
||||
pendingChecks.value = count(stabilities)
|
||||
|
||||
latestStandards.value = standards.slice(-5).reverse()
|
||||
latestStabilities.value = stabilities.slice(-5).reverse()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
function count(stabilities) {
|
||||
let count = 0
|
||||
for (const item of stabilities) {
|
||||
if (item.checks && Array.isArray(item.checks)) {
|
||||
for (const check of item.checks) {
|
||||
if (check.checked) continue
|
||||
const checkDate = new Date(check.date)
|
||||
if (subDate(checkDate, new Date()) < 30) {
|
||||
count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// 计算 d1 和 d2 之间的天数
|
||||
function subDate(d1, d2) {
|
||||
const diff = Math.abs(d1 - d2)
|
||||
const day = 24 * 60 * 60 * 1000
|
||||
return Math.ceil(diff / day)
|
||||
}
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card" @click="go('/standard')">
|
||||
<div class="stat-inner">
|
||||
<div class="stat-value">{{ standardCount }}</div>
|
||||
<div class="stat-label">对照品总数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card" @click="go('/stability')">
|
||||
<div class="stat-inner">
|
||||
<div class="stat-value">{{ stabilityCount }}</div>
|
||||
<div class="stat-label">稳定性实验总数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card warning" @click="go('/stability')">
|
||||
<div class="stat-inner">
|
||||
<div class="stat-value">{{ pendingChecks }}</div>
|
||||
<div class="stat-label">稳定性实验待取出项</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="recent-lists">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最近对照品</span>
|
||||
<el-button text type="primary" @click="go('/standard')">查看全部</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="latestStandards" size="small" stripe>
|
||||
<el-table-column prop="batch" label="批号" />
|
||||
<el-table-column prop="location" label="位置" />
|
||||
<el-table-column prop="calibration_date" label="标定日期">
|
||||
<template #default="{ row }">
|
||||
{{ row.calibration_date ? new Date(row.calibration_date).toLocaleDateString() : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="latestStandards.length === 0" description="暂无数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最近稳定性实验</span>
|
||||
<el-button text type="primary" @click="go('/stability')">查看全部</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="latestStabilities" size="small" stripe>
|
||||
<el-table-column prop="batch" label="批次" />
|
||||
<el-table-column prop="type" label="类型" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.ended ? 'success' : 'warning'" size="small">
|
||||
{{ row.ended ? '已完成' : '进行中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="latestStabilities.length === 0" description="暂无数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-inner {
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.stat-card.warning .stat-value {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.recent-lists {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header span {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
61
desktop-ui/src/views/Login.vue
Normal file
61
desktop-ui/src/views/Login.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, getCurrentInstance, ref } from 'vue'
|
||||
|
||||
const { $http } = getCurrentInstance().appContext.config.globalProperties
|
||||
|
||||
const pleaseLogin = ref(localStorage.getItem('token') == null)
|
||||
|
||||
const form = ref({
|
||||
username: "",
|
||||
password: ""
|
||||
})
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
ElMessage.error("请输入账号和密码!")
|
||||
return
|
||||
}
|
||||
const res = await $http.post("/user/login", form.value)
|
||||
const { token, user } = res.data
|
||||
localStorage.setItem("token", token)
|
||||
localStorage.setItem("userid", user._id)
|
||||
localStorage.setItem("nickname", user.nickname)
|
||||
localStorage.setItem("role", user.role)
|
||||
if (window.__updateUserInfo) {
|
||||
window.__updateUserInfo({ nickname: user.nickname, role: user.role })
|
||||
}
|
||||
ElMessage.success("登录成功!")
|
||||
pleaseLogin.value = false
|
||||
} catch (error) {
|
||||
console.log(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="pleaseLogin" width="500px" title="请登录" align-center :show-close="false"
|
||||
:close-on-click-modal="false" :close-on-press-escape="false">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密 码" required>
|
||||
<el-input v-model="form.password" show-password></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="login()">登录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
font-family:
|
||||
Inter, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
506
desktop-ui/src/views/Stability.vue
Normal file
506
desktop-ui/src/views/Stability.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowRight, ArrowDown } from '@element-plus/icons-vue'
|
||||
|
||||
const { $http } = getCurrentInstance().appContext.config.globalProperties
|
||||
|
||||
const isAdmin = localStorage.getItem('role') === 'admin'
|
||||
const data = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(false)
|
||||
const form = ref({
|
||||
batch: '',
|
||||
type: '长期',
|
||||
description: '',
|
||||
checks: [],
|
||||
})
|
||||
const newCheckDate = ref('')
|
||||
const newCheckDesc = ref('')
|
||||
const search = ref('')
|
||||
const expandAll = ref(false)
|
||||
const selectedIds = ref([])
|
||||
const expandRowKeys = ref([])
|
||||
|
||||
// 筛查天数(0 = 显示全部)
|
||||
const filterDays = ref(0)
|
||||
const filteredData = computed(() => {
|
||||
const filted = search.value == '' ? data.value : data.value.filter((v) => v.batch.includes(search.value))
|
||||
if (!filterDays.value || filterDays.value <= 0) return filted
|
||||
const now = new Date()
|
||||
const limit = new Date(now.getTime() + filterDays.value * 86400000)
|
||||
return filted.filter((item) => {
|
||||
if (item.ended) return false
|
||||
return (item.checks || []).some((chk) => {
|
||||
if (chk.checked) return false
|
||||
const chkDate = new Date(chk.date)
|
||||
return chkDate >= now && chkDate <= limit
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** 根据实验类型生成默认检查点(日期从今天开始计算) */
|
||||
function generateDefaultChecks(type) {
|
||||
const today = new Date()
|
||||
const monthsMap = {
|
||||
加速: [0, 1, 2, 3, 6],
|
||||
长期: [0, 3, 6, 12, 18, 24, 36, 48],
|
||||
}
|
||||
const months = monthsMap[type]
|
||||
if (!months) return []
|
||||
|
||||
return months.map((m) => {
|
||||
const date = new Date(today)
|
||||
date.setMonth(date.getMonth() + m)
|
||||
const y = date.getFullYear()
|
||||
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return {
|
||||
date: `${y}-${mo}-${d}`,
|
||||
description: `${m}个月`,
|
||||
checked: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await $http.get('/stability')
|
||||
const list = res.data || []
|
||||
// 未完成的排前面,已完成的排后面
|
||||
list.sort((a, b) => {
|
||||
if (a.ended === b.ended) return 0
|
||||
return a.ended ? 1 : -1
|
||||
})
|
||||
data.value = list
|
||||
// 只默认展开未完成的行
|
||||
expandRowKeys.value = list.filter((r) => !r.ended).map((r) => r._id)
|
||||
} catch {
|
||||
data.value = []
|
||||
expandRowKeys.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = false
|
||||
form.value = { batch: '', type: '长期', description: '', checks: [] }
|
||||
// 根据默认类型填充检查点
|
||||
form.value.checks = generateDefaultChecks('长期')
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 切换实验类型时自动重新生成检查点(仅新建模式)
|
||||
watch(
|
||||
() => form.value.type,
|
||||
(newType) => {
|
||||
if (!editing.value) {
|
||||
form.value.checks = generateDefaultChecks(newType)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function openEdit(row) {
|
||||
editing.value = true
|
||||
form.value = {
|
||||
batch: row.batch,
|
||||
type: row.type,
|
||||
description: row.description || '',
|
||||
checks: (row.checks || []).map((c) => ({ ...c })),
|
||||
}
|
||||
form.value._id = row._id
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function addCheck() {
|
||||
if (!newCheckDate.value) {
|
||||
ElMessage.error('请选择检查日期')
|
||||
return
|
||||
}
|
||||
form.value.checks.push({
|
||||
date: newCheckDate.value,
|
||||
description: newCheckDesc.value || '',
|
||||
checked: false,
|
||||
})
|
||||
newCheckDate.value = ''
|
||||
newCheckDesc.value = ''
|
||||
}
|
||||
|
||||
function removeCheck(index) {
|
||||
form.value.checks.splice(index, 1)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.batch) {
|
||||
ElMessage.error('批次号不能为空')
|
||||
return
|
||||
}
|
||||
if (form.value.checks.length === 0) {
|
||||
ElMessage.error('请至少添加一个检查点')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await $http.patch(`/stability/${form.value._id}`, {
|
||||
batch: form.value.batch,
|
||||
type: form.value.type,
|
||||
description: form.value.description,
|
||||
checks: form.value.checks,
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await $http.post('/stability', {
|
||||
batch: form.value.batch,
|
||||
type: form.value.type,
|
||||
description: form.value.description,
|
||||
checks: form.value.checks,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除批次"${row.batch}"的实验记录吗?`, '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await $http.delete(`/stability/${row._id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await refresh()
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
async function doCheck(row) {
|
||||
try {
|
||||
const check = row.checks.find((v) => !v.checked)
|
||||
await ElMessageBox.confirm(`确认取出批次 "${row.batch}" 的 "${check.description}" 样品?`, '确认取出', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
})
|
||||
await $http.post(`/stability/check/${row._id}/${check._id}`)
|
||||
ElMessage.success('已确认取出')
|
||||
await refresh()
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
function expandRow() {
|
||||
expandRowKeys.value = []
|
||||
expandAll.value = !expandAll.value
|
||||
if (expandAll.value) filteredData.value.forEach((v) => expandRowKeys.value.push(v._id))
|
||||
}
|
||||
|
||||
function filterStatus(value, row) {
|
||||
return row.ended == value
|
||||
}
|
||||
|
||||
function filterType(value, row) {
|
||||
return row.type == value
|
||||
}
|
||||
|
||||
function checkProgress(checks) {
|
||||
if (!checks || checks.length === 0) return 0
|
||||
const done = checks.filter((c) => c.checked).length
|
||||
return Math.round((done / checks.length) * 100)
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const d = new Date(date)
|
||||
let year = d.getFullYear()
|
||||
let month = (d.getMonth() + 1).toString().padStart(2, '0')
|
||||
let day = d.getDate().toString().padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="actions-bar">
|
||||
<div class="actions-left">
|
||||
<el-button @click="refresh">刷新</el-button>
|
||||
<el-button type="primary" @click="openCreate">新增实验</el-button>
|
||||
<el-input v-model="search" style="width: 240px" placeholder="输入进行搜索" clearable></el-input>
|
||||
</div>
|
||||
<div class="actions-right">
|
||||
<span class="filter-label">筛查</span>
|
||||
<el-input-number
|
||||
v-model="filterDays"
|
||||
:min="0"
|
||||
:max="365"
|
||||
:step="7"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
style="width: 100px"
|
||||
/>
|
||||
<span class="filter-unit">天内需取出</span>
|
||||
<el-tag v-if="filterDays > 0" type="warning" size="small" effect="plain" class="filter-hint">
|
||||
显示 {{ filteredData.length }} 条
|
||||
</el-tag>
|
||||
<el-button v-if="filterDays > 0" size="small" text type="info" @click="filterDays = 0">
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredData" stripe border style="width: 100%" row-key="_id" :expand-row-keys="expandRowKeys">
|
||||
<el-table-column type="expand">
|
||||
<template #header>
|
||||
<el-button
|
||||
text
|
||||
@click="expandRow"
|
||||
:icon="expandAll ? ArrowDown : ArrowRight"
|
||||
class="el-table__expand-icon"
|
||||
></el-button>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="expand-content">
|
||||
<el-steps :active="(row.checks || []).filter((c) => c.checked).length" align-center>
|
||||
<el-step
|
||||
v-for="chk in row.checks || []"
|
||||
:key="chk._id || chk.date"
|
||||
:title="chk.description || '-'"
|
||||
>
|
||||
<template #description>
|
||||
<div class="step-desc">
|
||||
<span>{{ formatDate(chk.date) }}</span>
|
||||
<span v-if="chk.checked && chk.operatorName" class="step-op"
|
||||
>👤{{ chk.operatorName }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template #icon>
|
||||
<el-tag
|
||||
:type="chk.checked ? 'success' : 'info'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
style="border: none; padding: 0 2px"
|
||||
>
|
||||
{{ chk.checked ? '✓' : '○' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-step>
|
||||
</el-steps>
|
||||
<el-empty v-if="!(row.checks && row.checks.length)" description="暂无检查点" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch" label="批次" min-width="160" />
|
||||
<el-table-column
|
||||
prop="type"
|
||||
label="类型"
|
||||
width="70"
|
||||
:filters="[
|
||||
{ text: '长期', value: '长期' },
|
||||
{ text: '加速', value: '加速' },
|
||||
{ text: '其它', value: '其它' },
|
||||
]"
|
||||
:filter-method="filterType"
|
||||
/>
|
||||
<el-table-column label="进度" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="checkProgress(row.checks)"
|
||||
:status="row.ended ? 'success' : undefined"
|
||||
:stroke-width="14"
|
||||
:text-inside="true"
|
||||
>
|
||||
<span
|
||||
>{{ row.checks ? row.checks.filter((c) => c.checked).length : 0 }}/{{
|
||||
row.checks ? row.checks.length : 0
|
||||
}}</span
|
||||
>
|
||||
</el-progress>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="状态"
|
||||
width="80"
|
||||
:filters="[
|
||||
{ text: '已完成', value: true },
|
||||
{ text: '进行中', value: false },
|
||||
]"
|
||||
:filter-method="filterStatus"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.ended ? 'success' : 'warning'" size="small" effect="plain">
|
||||
{{ row.ended ? '已完成' : '进行中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="desc-text">{{ row.description || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="success" :disabled="row.ended" @click="doCheck(row)">
|
||||
取出
|
||||
</el-button>
|
||||
<el-button size="small" :disabled="!isAdmin" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!isAdmin" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editing ? '编辑稳定性实验' : '新增稳定性实验'"
|
||||
width="620px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
>
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="批次号" required>
|
||||
<el-input v-model="form.batch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="实验类型">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio value="长期">长期</el-radio>
|
||||
<el-radio value="加速">加速</el-radio>
|
||||
<el-radio value="其它">其它</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider>
|
||||
<span style="font-size: 13px; color: var(--el-text-color-secondary)">检查点设置</span>
|
||||
</el-divider>
|
||||
|
||||
<div class="check-add-row">
|
||||
<el-date-picker
|
||||
v-model="newCheckDate"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<el-input v-model="newCheckDesc" placeholder="描述(如:0个月)" style="width: 180px" />
|
||||
<el-button type="primary" @click="addCheck">添加</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="form.checks" stripe size="small" style="margin-top: 12px">
|
||||
<el-table-column label="日期" width="140">
|
||||
<template #default="{ row: chk }">
|
||||
{{ formatDate(chk.date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="120" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row: chk }">
|
||||
<el-tag :type="chk.checked ? 'success' : 'info'" size="small">
|
||||
{{ chk.checked ? '已取出' : '待取出' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="60">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" link @click="removeCheck($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
color: #8896a8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-unit {
|
||||
font-size: 13px;
|
||||
color: #3d4a5c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===== 步骤条(展开内容) ===== */
|
||||
.expand-content {
|
||||
padding: 16px 32px;
|
||||
}
|
||||
|
||||
.expand-content .el-steps {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.step-op {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
263
desktop-ui/src/views/Standard.vue
Normal file
263
desktop-ui/src/views/Standard.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue"
|
||||
import { getCurrentInstance } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
|
||||
const { $http } = getCurrentInstance().appContext.config.globalProperties
|
||||
|
||||
const isAdmin = localStorage.getItem('role') === 'admin'
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return "-"
|
||||
const d = new Date(date)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
const data = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(false)
|
||||
const form = ref({
|
||||
batch: "",
|
||||
im: "",
|
||||
ass: "",
|
||||
calibration_date: "",
|
||||
expire_date: "",
|
||||
location: "",
|
||||
})
|
||||
|
||||
// 有效期筛查(天数,0=全部)
|
||||
const filterDays = ref(0)
|
||||
const filteredData = computed(() => {
|
||||
if (!filterDays.value || filterDays.value <= 0) return data.value
|
||||
const now = new Date()
|
||||
const limit = new Date(now.getTime() + filterDays.value * 86400000)
|
||||
return data.value.filter((item) => {
|
||||
if (!item.expire_date) return false
|
||||
const expire = new Date(item.expire_date)
|
||||
return expire >= now && expire <= limit
|
||||
})
|
||||
})
|
||||
|
||||
function rowClass({ row }) {
|
||||
if (!row.expire_date) return ""
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const expire = new Date(row.expire_date)
|
||||
expire.setHours(0, 0, 0, 0)
|
||||
return expire < today ? "row-expired" : ""
|
||||
}
|
||||
|
||||
function rowStyle({ row }) {
|
||||
if (!row.expire_date) return {}
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const expire = new Date(row.expire_date)
|
||||
expire.setHours(0, 0, 0, 0)
|
||||
if (expire < today) {
|
||||
return { color: "#dc2626" }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await $http.get("/standard")
|
||||
data.value = res.data || []
|
||||
} catch {
|
||||
data.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = false
|
||||
form.value = { batch: "", im: "", ass: "", calibration_date: "", expire_date: "", location: "" }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editing.value = true
|
||||
form.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.batch) {
|
||||
ElMessage.error("批号不能为空")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await $http.patch(`/standard/${form.value._id}`, form.value)
|
||||
ElMessage.success("更新成功")
|
||||
} else {
|
||||
await $http.post("/standard", form.value)
|
||||
ElMessage.success("创建成功")
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || "操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除批号为"${row.batch}"的对照品吗?`, "确认删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
await $http.delete(`/standard/${row._id}`)
|
||||
ElMessage.success("删除成功")
|
||||
await refresh()
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="actions-bar">
|
||||
<div class="actions-left">
|
||||
<el-button @click="refresh">刷新</el-button>
|
||||
<el-button type="primary" @click="openCreate">新增对照品</el-button>
|
||||
</div>
|
||||
<div class="actions-right">
|
||||
<span class="filter-label">有效期</span>
|
||||
<el-input-number v-model="filterDays" :min="0" :max="365" :step="7" size="small"
|
||||
controls-position="right" style="width: 100px" />
|
||||
<span class="filter-unit">天内到期</span>
|
||||
<el-tag v-if="filterDays > 0" type="warning" size="small" effect="plain">
|
||||
显示 {{ filteredData.length }} 条
|
||||
</el-tag>
|
||||
<el-button v-if="filterDays > 0" size="small" text type="info" @click="filterDays = 0">
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredData" stripe border style="width: 100%" :row-class-name="rowClass"
|
||||
:row-style="rowStyle">
|
||||
<el-table-column prop="batch" label="批号" min-width="180" />
|
||||
<el-table-column prop="im" label="含量(%)" width="100" />
|
||||
<el-table-column prop="ass" label="纯度(%)" width="100" />
|
||||
<el-table-column prop="calibration_date" label="标定日期" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.calibration_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expire_date" label="有效期" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.expire_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="存放位置" min-width="140" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!isAdmin" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑对照品' : '新增对照品'" width="520px" align-center>
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="批号" required>
|
||||
<el-input v-model="form.batch" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="含量(%)">
|
||||
<el-input v-model="form.im" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="纯度(%)">
|
||||
<el-input v-model="form.ass" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="标定日期">
|
||||
<el-date-picker v-model="form.calibration_date" type="date" style="width: 100%"
|
||||
value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="有效期">
|
||||
<el-date-picker v-model="form.expire_date" type="date" style="width: 100%"
|
||||
value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="存放位置">
|
||||
<el-input v-model="form.location" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
color: #8896a8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-unit {
|
||||
font-size: 13px;
|
||||
color: #3d4a5c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 过期行文字红色 */
|
||||
:deep(.el-table .row-expired) {
|
||||
color: #dc2626 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.row-expired:hover) {
|
||||
background-color: #fee2e2 !important;
|
||||
}
|
||||
|
||||
:deep(.row-expired .el-table__cell) {
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
18
desktop-ui/vite.config.js
Normal file
18
desktop-ui/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user