Compare commits

...

2 Commits

Author SHA1 Message Date
70d0dfc427 feat: add desktop-ui frontend 2026-05-11 16:04:31 +08:00
e24a34deb4 feat: add backend server 2026-05-11 16:01:22 +08:00
26 changed files with 6455 additions and 0 deletions

38
desktop-ui/.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

346
desktop-ui/src/App.vue Normal file
View 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/roleLogin.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
View 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')

View 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;

View 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>

View 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>

View 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>

View 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>

View 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
View 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))
},
},
})

3
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
http-test/

View File

@@ -0,0 +1,99 @@
import express from 'express'
import { requireAuth, requireAdmin } from './user.js'
import Stability from '../model/stability.js'
const router = express.Router()
// 列出所有稳定性实验
router.get('/', requireAuth, async (req, res) => {
try {
const stabilities = await Stability.find().populate(['checks.checker', 'creater'])
res.json(stabilities)
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 列出未完成的稳定性实验
router.get('/unend', requireAuth, async (req, res) => {
try {
const statilities = await Stability.find({ ended: false }).populate(['checks.checker', 'creater'])
res.json(statilities)
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 创建稳定性实验
router.post('/', requireAuth, async (req, res) => {
if (!req.body.batch) {
return res.status(400).json({ message: '缺少必要字段' })
}
try {
const stability = await Stability.create({
creater: req.user.id,
...req.body,
})
if (stability) {
res.json(stability)
} else {
res.status(400).json({ message: '资源创建失败' })
}
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 删除稳定性实验
router.delete('/:id', requireAuth, requireAdmin, async (req, res) => {
try {
const stability = await Stability.findByIdAndDelete(req.params.id)
if (stability) {
res.json({ message: '删除成功' })
} else {
res.status(404).json({ message: '删除失败' })
}
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 对 指定的稳定性实验进行check
// id稳定性实验id
// checkid检查点id
router.post('/check/:id/:checkid', requireAuth, async (req, res) => {
try {
const stability = await Stability.findById(req.params.id)
if (!stability) {
return res.status(404).json({ message: `未找到id为${req.params.id}的记录` })
}
stability.checks.forEach((check) => {
if (check._id == req.params.checkid) {
check.checked = true
check.checker = req.user.id
}
})
stability.ended = stability.checks.every((check) => check.checked)
await stability.save()
res.json(stability)
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 更新稳定性实验
router.patch('/:id', requireAuth, async (req, res) => {
try {
const stability = await Stability.findByIdAndUpdate(req.params.id, req.body, { returnDocument: 'after' })
if (stability) {
res.json(stability)
} else {
res.status(404).json({ message: `未找到id为${req.params.id}的记录` })
}
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
export { router as stabilityRouter }

View File

@@ -0,0 +1,65 @@
import express from 'express'
import Standard from '../model/standard.js'
import { requireAuth, requireAdmin } from './user.js'
const router = express.Router()
// 获取对照品
router.get('/', requireAuth, async (req, res) => {
try {
const standard = await Standard.find()
res.json(standard)
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 创建对照品
router.post('/', requireAuth, async (req, res) => {
if (!req.body.batch) {
return res.status(400).json({ message: '缺少必要字段' })
}
try {
const standard = await Standard.create({ ...req.body })
if (standard) {
res.json(standard)
} else {
res.status(400).json({ message: '资源创建失败' })
}
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 删除对照品
router.delete('/:id', requireAuth, requireAdmin, async (req, res) => {
try {
const standard = await Standard.findByIdAndDelete(req.params.id)
if (standard) {
res.json({ message: '删除成功' })
} else {
res.status(404).json({ message: '删除成功' })
}
} catch (error) {
res.status(500).json({ message: '服务器错误', error: error.message })
}
})
// 更新对照品
router.patch('/:id', requireAuth, async (req, res) => {
try {
console.log(req.params.id, req.body)
const standard = await Standard.findByIdAndUpdate(req.params.id, req.body, {
returnDocument: 'after',
})
res.json(standard)
} catch (error) {
res.status(500).json({
message: '服务器错误',
error: error.message,
})
}
})
export { router as standardRouter }

103
server/controller/user.js Normal file
View File

@@ -0,0 +1,103 @@
import express from 'express'
import jwt from 'jsonwebtoken'
import User from '../model/user.js'
const router = express.Router()
// 获取所有用户
router.get('/', requireAuth, requireAdmin, async (req, res) => {
try {
const users = await User.find().select('-password')
res.json(users)
} catch (error) {
res.status(500).json({ message: '获取用户信息时发生错误', error: error.message })
}
})
// 创建新用户
router.post('/', requireAuth, requireAdmin, async (req, res) => {
try {
const { username, nickname, password } = req.body
const user = await User.create({
username,
nickname,
password,
role: 'user',
})
res.status(201).json(user)
} catch (error) {
res.status(400).json({ message: '创建用户时发生错误', error: error.message })
}
})
// 删除用户
router.delete('/:id', requireAuth, requireAdmin, async (req, res) => {
try {
if (req.user._id.toString() === req.params.id) {
return res.status(400).json({ message: '不能删除你自己' })
}
const user = await User.findByIdAndDelete(req.params.id)
if (user) {
res.json({ message: '已删除用户' })
} else {
res.status(404).json({ message: '该用户不存在' })
}
} catch (error) {
res.status(500).json({ message: '删除用户时发生错误', error: error.message })
}
})
// 用户登录
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body
const user = await User.findOne({ username })
if (user && (await user.comparePassword(password))) {
const token = jwt.sign({ id: user._id, username: user.username }, process.env.JWT_SECRET, {
expiresIn: '24h',
})
res.json({ token, user })
} else {
res.status(401).json({ message: '用户名或密码错误' })
}
} catch (error) {
res.status(500).json({ message: '登录过程中发生错误', error: error.message })
}
})
/**
* 验证用户身份,拒绝未认证的请求
*/
async function requireAuth(req, res, next) {
const authHeader = req.headers['authorization']
if (!authHeader) {
return res.status(401).json({ message: '未提供身份验证信息' })
}
const token = authHeader.split(' ').pop()
if (!token) {
return res.status(401).json({ message: '提供了错误的身份验证信息' })
}
jwt.verify(token, process.env.JWT_SECRET, async (err, decoded) => {
if (err) {
return res.status(401).json({ message: '身份验证失败' })
}
// 从数据库获取完整的用户信息
req.user = await User.findById(decoded.id)
next()
})
}
/**
* 检查用户是否具有管理员权限调用前必须先调用requireAuth中间件验证用户身份
*/
async function requireAdmin(req, res, next) {
if (req.user && req.user.role === 'admin') {
next()
} else {
res.status(403).json({ message: '需要管理员权限' })
}
}
export { router as userRouter, requireAuth, requireAdmin }

24
server/database.js Normal file
View File

@@ -0,0 +1,24 @@
import mongoose from 'mongoose'
import User from './model/user.js'
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGO_URI)
console.log(`MongoDB Connected: ${conn.connection.host}`)
// 检查是否存在管理员用户,如果不存在则创建一个默认管理员
const adminUser = await User.findOne({ username: process.env.ADMIN_USERNAME })
if (!adminUser) {
await User.create({
username: process.env.ADMIN_USERNAME,
nickname: '管理员',
password: process.env.ADMIN_PASSWORD,
role: 'admin',
})
}
} catch (error) {
console.error('Error connecting to MongoDB:', error)
process.exit(1)
}
}
export default connectDB

26
server/index.js Normal file
View File

@@ -0,0 +1,26 @@
import dotenv from 'dotenv'
dotenv.config()
// 连接数据库
import connectDB from './database.js'
await connectDB()
import express from 'express'
import { userRouter } from './controller/user.js'
import { stabilityRouter } from './controller/stability.js'
import { standardRouter } from './controller/standard.js'
const app = express()
app.use(express.json())
// 注册路由
app.use('/api/user', userRouter)
app.use('/api/stability', stabilityRouter)
app.use('/api/standard', standardRouter)
// 从环境变量中读取端口号,如果环境变量中没有指定端口号,则使用默认的端口号
const PORT = process.env.PORT || 5000
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`)
})

79
server/model/stability.js Normal file
View File

@@ -0,0 +1,79 @@
import mongoose from 'mongoose'
import User from './user.js'
const CheckSchema = new mongoose.Schema(
{
// 检查日期
date: {
type: Date,
required: true,
},
// 是否已经检查
checked: {
type: Boolean,
required: true,
default: false,
},
// 检查人
checker: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
// 备注
description: {
type: String,
},
},
{ timestamps: true }
)
// 稳定性实验 模型
const StabilitySchema = new mongoose.Schema(
{
// 实验批次号
batch: {
type: String,
required: true,
},
// 实验类型
type: {
type: String,
enum: ['长期', '加速', '其它'],
},
// 检查点列表
checks: {
type: [CheckSchema],
default: [],
},
// 是否完成
ended: {
type: Boolean,
default: false,
},
// 创建人
creater: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
// 备注
description: {
type: String,
},
},
{ timestamps: true }
)
StabilitySchema.pre('save', async function () {
try {
this.checks = this.checks.sort((a, b) => {
return new Date(a.date) - new Date(b.date)
})
} catch (error) {
console.log(error)
}
})
const Stability = mongoose.model('Stability', StabilitySchema)
export default Stability

31
server/model/standard.js Normal file
View File

@@ -0,0 +1,31 @@
import mongoose from 'mongoose'
const StandardSchema = new mongoose.Schema(
{
batch: {
type: String,
require: true,
unique: true,
},
im: {
type: String,
},
ass: {
type: String,
},
calibration_date: {
type: Date,
},
expire_date: {
type: Date,
},
location: {
type: String,
},
},
{ timestamps: true }
)
const Standard = mongoose.model('Standard', StandardSchema)
export default Standard

47
server/model/user.js Normal file
View File

@@ -0,0 +1,47 @@
import mongoose from 'mongoose'
import bcrypt from 'bcrypt'
const UserSchema = new mongoose.Schema(
{
username: {
type: String,
required: true,
unique: true,
index: true,
},
nickname: {
type: String,
},
password: {
type: String,
required: true,
},
role: {
type: String,
enum: ['admin', 'user'],
default: 'user',
},
},
{ timestamps: true }
)
// 保存前自动对密码进行哈希加密
UserSchema.pre('save', async function () {
this.password = await bcrypt.hash(this.password, 10)
})
// 实例方法:校验密码
UserSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password)
}
// 输出时始终不返回密码
UserSchema.methods.toJSON = function () {
const obj = this.toObject()
delete obj.password
return obj
}
const User = mongoose.model('User', UserSchema)
export default User

1213
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
server/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "hbk01",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "nodemon index.js"
},
"dependencies": {
"bcrypt": "^6.0.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.5.0"
}
}