dedsudiyu 3 weken geleden
commit
03d62a23d8

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+node_modules
+/dist
+.DS_Store
+*.local

+ 5 - 0
babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    ['@vue/cli-plugin-babel/preset']
+  ]
+}

File diff suppressed because it is too large
+ 7709 - 0
package-lock.json


+ 33 - 0
package.json

@@ -0,0 +1,33 @@
+{
+  "name": "device-management-system",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build"
+  },
+  "dependencies": {
+    "axios": "^1.4.0",
+    "core-js": "^3.8.3",
+    "echarts": "^5.4.3",
+    "element-ui": "^2.15.14",
+    "js-md5": "^0.8.3",
+    "qrcode": "^1.5.4",
+    "vue": "^2.7.14",
+    "vue-router": "^3.6.5",
+    "vuex": "^3.6.2"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "~5.0.8",
+    "@vue/cli-service": "~5.0.8",
+    "sass": "^1.64.0",
+    "sass-loader": "^12.6.0",
+    "vue-template-compiler": "^2.7.14"
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead",
+    "not ie 11"
+  ]
+}

+ 15 - 0
public/index.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <title>设备管理系统</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>请启用 JavaScript 以正常使用本系统。</strong>
+    </noscript>
+    <div id="app"></div>
+  </body>
+</html>

+ 17 - 0
src/App.vue

@@ -0,0 +1,17 @@
+<template>
+  <div id="app">
+    <router-view />
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'App'
+}
+</script>
+
+<style lang="scss">
+#app {
+  height: 100%;
+}
+</style>

+ 26 - 0
src/api/auth.js

@@ -0,0 +1,26 @@
+/**
+ * 认证相关接口
+ * GET  /auth/captcha  — 获取图片验证码(Base64 + captchaKey)
+ * POST /auth/login    — 用户名密码登录
+ * POST /auth/logout   — 退出登录
+ */
+import request from '@/utils/request'
+
+/** 获取图片验证码 → { captchaKey, captchaImage } */
+export function getCaptcha() {
+  return request.get('/auth/captcha')
+}
+
+/**
+ * 用户登录
+ * @param {{ username, password, captchaKey, captchaCode }} data
+ * @returns {{ token, userId, userName, nickName }}
+ */
+export function login(data) {
+  return request.post('/auth/login', data)
+}
+
+/** 退出登录 */
+export function logout() {
+  return request.post('/auth/logout')
+}

+ 126 - 0
src/api/device.js

@@ -0,0 +1,126 @@
+/**
+ * 设备管理接口
+ * POST /lab/device/list          — 分页列表
+ * GET  /lab/device/detail/{id}   — 设备详情
+ * POST /lab/device/add           — 新增设备
+ * POST /lab/device/update        — 编辑设备
+ * POST /lab/device/del           — 删除(批量)
+ * POST /lab/device/export        — 导出 Excel(文件流)
+ * POST /lab/device/import        — 批量导入 Excel
+ * POST /lab/device/qrcode/batch  — 批量下载二维码 ZIP
+ *
+ * 字段映射(API DeviceVo → 内部):
+ *   id, deviceCode→deviceNo, deviceName→name, subName→labName
+ *   adminDisplay→manager, deviceStatus整型→status文字, deviceStatusName→statusName
+ *   categoryName→category, brand, deviceModel→model, purchaseDate→inboundDate
+ */
+import request from '@/utils/request'
+
+const STATUS_TEXT = { 0: '空闲', 1: '使用中', 2: '维修中', 3: '已报废' }
+
+function normalizeDevice(d) {
+  return {
+    id:          String(d.id),
+    deviceNo:    d.deviceCode || '',
+    name:        d.deviceName || '',
+    category:    d.categoryName || '',
+    model:       d.deviceModel || '',
+    status:      STATUS_TEXT[d.deviceStatus] ?? String(d.deviceStatus),
+    statusCode:  d.deviceStatus,
+    manager:     d.adminDisplay || '',
+    adminId:     String(d.adminId || ''),
+    labName:     d.subName || '',
+    subId:       String(d.subId || ''),
+    roomNo:      d.roomNo || '',
+    brand:       d.brand || '',
+    inboundDate: d.purchaseDate || '',
+    imageUrls:   d.imageUrls || [],
+    remark:      d.remark || '',
+    createdAt:   d.createTime || ''
+  }
+}
+
+/** 设备分页列表 */
+export function getDeviceList(params = {}) {
+  return request.post('/lab/device/list', {
+    page:         params.page || 1,
+    pageSize:     params.pageSize || 10,
+    deviceCode:   params.deviceNo || '',
+    deviceName:   params.name || '',
+    subName:      params.labName || '',
+    deviceStatus: params.statusCode !== undefined && params.statusCode !== '' ? params.statusCode : undefined
+  }).then(res => ({
+    ...res,
+    data: {
+      list:  (res.data?.records || []).map(normalizeDevice),
+      total: res.data?.total || 0
+    }
+  }))
+}
+
+/** 设备详情 */
+export function getDeviceDetail(id) {
+  return request.get(`/lab/device/detail/${id}`).then(res => ({
+    ...res,
+    data: normalizeDevice(res.data || {})
+  }))
+}
+
+/** 新增 / 编辑设备 */
+export function saveDevice(data) {
+  const body = {
+    deviceCode:    data.deviceNo,
+    deviceName:    data.name,
+    subId:         data.subId,
+    roomNo:        data.roomNo || '',
+    categoryId:    data.categoryId || '',
+    adminId:       data.adminId,
+    deviceStatus:  data.statusCode ?? 0,
+    brand:         data.brand || '',
+    deviceModel:   data.model || '',
+    purchaseDate:  data.inboundDate || '',
+    imageUrls:     data.imageUrls || [],
+    remark:        data.remark || ''
+  }
+  if (data.id) {
+    return request.post('/lab/device/update', { ...body, id: data.id })
+  }
+  return request.post('/lab/device/add', body)
+}
+
+/** 删除设备(单条或批量) */
+export function deleteDevice(ids) {
+  const idArr = Array.isArray(ids) ? ids : [ids]
+  return request.post('/lab/device/del', { ids: idArr.map(String) })
+}
+
+/** 导出 Excel(返回 Blob) */
+export function exportDevices(params = {}) {
+  return request.post('/lab/device/export', {
+    deviceCode:   params.deviceNo || '',
+    deviceName:   params.name || '',
+    subName:      params.labName || '',
+    deviceStatus: params.statusCode !== undefined && params.statusCode !== '' ? params.statusCode : undefined
+  }, { responseType: 'blob' })
+}
+
+/** 批量导入 Excel */
+export function importDevices(file) {
+  const fd = new FormData()
+  fd.append('file', file)
+  return request.post('/lab/device/import', fd, {
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+/** 批量下载设备二维码 ZIP */
+export function batchDownloadQrCode(ids) {
+  return request.post('/lab/device/qrcode/batch', {
+    ids: ids.map(String)
+  }, { responseType: 'blob' })
+}
+
+// ---- 以下保留供旧代码兼容 ----
+export const DEVICE_CATEGORIES = []
+export const DEVICE_DEPTS = []
+export const DEVICE_STATUSES = ['空闲', '使用中', '维修中', '已报废']

+ 9 - 0
src/api/index.js

@@ -0,0 +1,9 @@
+// axios 实例封装(待步骤3实现)
+import axios from 'axios'
+
+const service = axios.create({
+  baseURL: process.env.VUE_APP_BASE_API || '/',
+  timeout: 5000
+})
+
+export default service

+ 85 - 0
src/api/menu.js

@@ -0,0 +1,85 @@
+/**
+ * 菜单管理接口
+ * GET  /sys/menu/list    — 树形列表(管理页面)
+ * GET  /sys/menu/tree    — 完整菜单树(权限配置用)
+ * POST /sys/menu/add     — 新增菜单
+ * POST /sys/menu/update  — 修改菜单
+ * POST /sys/menu/del/{id} — 删除菜单
+ *
+ * 注:API 字段与内部字段的映射:
+ *   menuId    → id        menuName → name
+ *   menuType  → type      (0=dir,1=menu,2=btn)
+ *   orderNum  → sort      perms    → permission
+ *   visible(bool) → visible(0/1)
+ */
+import request from '@/utils/request'
+
+const TYPE_MAP   = { 0: 'dir', 1: 'menu', 2: 'btn' }
+const TYPE_RMAP  = { dir: 0, menu: 1, btn: 2 }
+
+/** 将 API MenuTreeNode 标准化为内部格式 */
+function normalizeNode(node) {
+  const children = node.children && node.children.length > 0
+    ? node.children.map(normalizeNode)
+    : undefined
+  return {
+    id:         String(node.menuId),
+    parentId:   String(node.parentId || '0'),
+    name:       node.menuName,
+    icon:       node.icon || '',
+    sort:       node.orderNum,
+    permission: node.perms || '',
+    path:       node.path || '',
+    component:  node.component || '',
+    type:       TYPE_MAP[node.menuType] ?? 'menu',
+    visible:    node.visible ? 1 : 0,
+    status:     1,
+    createdAt:  node.createTime || '',
+    children
+  }
+}
+
+/** 将内部格式序列化为 API 所需格式 */
+function denormalizeNode(data) {
+  return {
+    parentId:  String(data.parentId || '0'),
+    menuName:  data.name,
+    menuType:  TYPE_RMAP[data.type] ?? 1,
+    orderNum:  data.sort,
+    path:      data.path || '',
+    component: data.component || '',
+    perms:     data.permission || '',
+    icon:      data.icon || '',
+    visible:   !!data.visible
+  }
+}
+
+/** 树形列表(管理页面展示) */
+export function getMenuList(params = {}) {
+  return request.get('/sys/menu/list', { params }).then(res => ({
+    ...res,
+    data: (res.data || []).map(normalizeNode)
+  }))
+}
+
+/** 完整菜单树(权限配置页使用) */
+export function getMenuTree() {
+  return request.get('/sys/menu/tree').then(res => ({
+    ...res,
+    data: (res.data || []).map(normalizeNode)
+  }))
+}
+
+/** 新增或编辑菜单 */
+export function saveMenu(data) {
+  const body = denormalizeNode(data)
+  if (data.id) {
+    return request.post('/sys/menu/update', { ...body, menuId: data.id })
+  }
+  return request.post('/sys/menu/add', body)
+}
+
+/** 删除菜单(含子菜单级联由后端处理) */
+export function deleteMenu(id) {
+  return request.post(`/sys/menu/del/${id}`)
+}

+ 37 - 0
src/api/product.js

@@ -0,0 +1,37 @@
+/**
+ * 商品管理 mock 接口
+ * 使用 setTimeout 模拟异步请求
+ */
+
+const mockProducts = [
+  { id: 1,  name: 'iPhone 15 Pro',          price: 8999,  stock: 128, status: 1 },
+  { id: 2,  name: 'MacBook Pro 14"',         price: 14999, stock: 45,  status: 1 },
+  { id: 3,  name: 'iPad Air 5',              price: 4799,  stock: 200, status: 1 },
+  { id: 4,  name: 'AirPods Pro 2',           price: 1899,  stock: 350, status: 1 },
+  { id: 5,  name: 'Apple Watch Series 9',    price: 2999,  stock: 88,  status: 0 },
+  { id: 6,  name: 'Samsung Galaxy S24',      price: 5999,  stock: 76,  status: 1 },
+  { id: 7,  name: 'Sony WH-1000XM5',        price: 2299,  stock: 124, status: 1 },
+  { id: 8,  name: 'DJI Mini 4 Pro',          price: 4799,  stock: 32,  status: 1 },
+  { id: 9,  name: 'Nintendo Switch OLED',    price: 2399,  stock: 0,   status: 0 },
+  { id: 10, name: 'Kindle Paperwhite',       price: 999,   stock: 256, status: 1 },
+  { id: 11, name: '小米 14 Ultra',           price: 5999,  stock: 165, status: 1 },
+  { id: 12, name: 'OPPO Find X7 Ultra',      price: 4499,  stock: 80,  status: 1 }
+]
+
+/**
+ * 获取商品列表
+ * @returns {Promise<{code, data: {list, total}}>}
+ */
+export function getProductList() {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      resolve({
+        code: 200,
+        data: {
+          list: [...mockProducts],
+          total: mockProducts.length
+        }
+      })
+    }, 300)
+  })
+}

+ 95 - 0
src/api/role.js

@@ -0,0 +1,95 @@
+/**
+ * 角色管理接口
+ * POST /sys/role/list         — 分页列表
+ * POST /sys/role/add          — 新增角色
+ * POST /sys/role/update       — 编辑角色
+ * POST /sys/role/del          — 删除(支持批量)
+ * GET  /sys/role/permission/{roleId} — 获取已分配菜单ID数组
+ * POST /sys/role/permission   — 保存角色菜单权限
+ *
+ * API dataScope 整型映射:
+ *   1=全部数据权限  2=本部门及以下  3=本部门  4=本人  5=自定义
+ */
+import request from '@/utils/request'
+
+export const DATA_SCOPES = [
+  '全部数据权限',
+  '本部门及以下数据权限',
+  '本部门数据权限',
+  '本人数据权限',
+  '自定义数据权限'
+]
+
+const SCOPE_TO_INT = {
+  '全部数据权限': 1,
+  '本部门及以下数据权限': 2,
+  '本部门数据权限': 3,
+  '本人数据权限': 4,
+  '自定义数据权限': 5
+}
+const INT_TO_SCOPE = { 1: '全部数据权限', 2: '本部门及以下数据权限', 3: '本部门数据权限', 4: '本人数据权限', 5: '自定义数据权限' }
+
+/** API RoleVo → 内部格式 */
+function normalizeRole(r) {
+  return {
+    id:        String(r.roleId),
+    name:      r.roleName,
+    roleKey:   r.roleKey || '',
+    permType:  r.dataScope === 1 ? 'all' : 'custom',
+    dataScope: INT_TO_SCOPE[r.dataScope] || '自定义数据权限',
+    sort:      r.roleSort,
+    status:    r.state ? 1 : 0,
+    remark:    r.roleDesc || '',
+    createdAt: r.createTime || ''
+  }
+}
+
+/** 分页列表 */
+export function getRoleList(params = {}) {
+  return request.post('/sys/role/list', {
+    page:     params.page || 1,
+    pageSize: params.pageSize || 100,
+    roleName: params.name || '',
+    state:    params.status !== '' ? params.status === 1 : undefined
+  }).then(res => ({
+    ...res,
+    data: {
+      list:  (res.data?.records || []).map(normalizeRole),
+      total: res.data?.total || 0
+    }
+  }))
+}
+
+/** 新增 / 编辑角色 */
+export function saveRole(data) {
+  const body = {
+    roleName:  data.name,
+    roleKey:   data.roleKey || '',
+    dataScope: SCOPE_TO_INT[data.dataScope] || 5,
+    roleSort:  data.sort,
+    state:     data.status === 1,
+    roleDesc:  data.remark || ''
+  }
+  if (data.id) {
+    return request.post('/sys/role/update', { ...body, roleId: data.id })
+  }
+  return request.post('/sys/role/add', body)
+}
+
+/** 删除角色 */
+export function deleteRole(id) {
+  return request.post('/sys/role/del', { ids: [String(id)] })
+}
+
+/** 获取角色已分配的菜单/按钮ID数组 → string[] */
+export function getRolePermission(roleId) {
+  return request.get(`/sys/role/permission/${roleId}`)
+}
+
+/** 保存角色权限(发送菜单+按钮ID数组) */
+export function saveRolePermission(roleId, menuIds) {
+  return request.post('/sys/role/permission', {
+    roleId:  String(roleId),
+    menuIds: menuIds.map(String)
+  })
+}

+ 60 - 0
src/api/usage.js

@@ -0,0 +1,60 @@
+/**
+ * 设备使用记录接口(PC端)
+ * POST /lab/device/usage/list   — 分页列表
+ * POST /lab/device/usage/export — 导出 Excel
+ *
+ * API DeviceUsageVo 字段 → 内部字段:
+ *   deviceCode→deviceNo, deviceName→deviceName, subName→labName
+ *   userName→user, usageTypeName→usageType(文字)
+ *   startTime, actualEndTime→endTime, expectedEndTime→expectedShutdown
+ *   usageStatusName→statusName
+ */
+import request from '@/utils/request'
+
+function normalizeUsage(u) {
+  return {
+    id:               String(u.id),
+    deviceNo:         u.deviceCode || '',
+    deviceName:       u.deviceName || '',
+    labName:          u.subName || '',
+    usageType:        u.usageTypeName || '',
+    startTime:        u.startTime || '',
+    endTime:          u.actualEndTime || '',
+    expectedShutdown: u.expectedEndTime || '',
+    user:             u.userName || '',
+    userDept:         u.userDept || '',
+    recordTime:       u.createTime || u.startTime || '',
+    statusName:       u.usageStatusName || '',
+    statusCode:       u.usageStatus
+  }
+}
+
+/** 使用记录分页列表 */
+export function getUsageList(params = {}) {
+  return request.post('/lab/device/usage/list', {
+    page:        params.page || 1,
+    pageSize:    params.pageSize || 10,
+    deviceCode:  params.deviceNo || '',
+    deviceName:  params.deviceName || '',
+    userName:    params.user || '',
+    usageStatus: (params.statusCode !== undefined && params.statusCode !== '') ? params.statusCode : undefined
+  }).then(res => ({
+    ...res,
+    data: {
+      list:  (res.data?.records || []).map(normalizeUsage),
+      total: res.data?.total || 0
+    }
+  }))
+}
+
+/** 导出使用记录 Excel */
+export function exportUsageList(params = {}) {
+  return request.post('/lab/device/usage/export', {
+    deviceCode:  params.deviceNo || '',
+    deviceName:  params.deviceName || '',
+    userName:    params.user || '',
+    usageStatus: (params.statusCode !== undefined && params.statusCode !== '') ? params.statusCode : undefined
+  }, { responseType: 'blob' })
+}
+
+export const USAGE_TYPES = ['教学', '科研', '其他']

+ 52 - 0
src/api/user.js

@@ -0,0 +1,52 @@
+/**
+ * 用户管理 mock 接口
+ */
+
+const mockUsers = [
+  { id: 1,  name: '张三',   workNo: '2008111234', role: '实验室管理员', dept: '部门1/一组', gender: 'male',   phone: '13800138001', status: 1, createdAt: '2024-01-15 09:23:00' },
+  { id: 2,  name: '李四',   workNo: '2008222345', role: '设备管理员',   dept: '部门1/二组', gender: 'male',   phone: '13800138002', status: 1, createdAt: '2024-01-28 14:05:00' },
+  { id: 3,  name: '王五',   workNo: '2008333456', role: '普通教师',     dept: '部门2/一组', gender: 'male',   phone: '13800138003', status: 0, createdAt: '2024-02-03 11:42:00' },
+  { id: 4,  name: '赵六',   workNo: '2008444567', role: '普通教师',     dept: '部门2/二组', gender: 'male',   phone: '13800138004', status: 1, createdAt: '2024-02-17 08:30:00' },
+  { id: 5,  name: '钱七',   workNo: '2008555678', role: '学生',         dept: '部门1/一组', gender: 'female', phone: '13800138005', status: 1, createdAt: '2024-03-05 16:18:00' },
+  { id: 6,  name: '孙八',   workNo: '2008666789', role: '学生',         dept: '部门1/二组', gender: 'female', phone: '13800138006', status: 1, createdAt: '2024-03-22 10:55:00' },
+  { id: 7,  name: '周九',   workNo: '2008777890', role: '普通教师',     dept: '部门2/一组', gender: 'male',   phone: '13800138007', status: 0, createdAt: '2024-04-01 13:07:00' },
+  { id: 8,  name: '吴十',   workNo: '2008888901', role: '设备管理员',   dept: '部门2/二组', gender: 'male',   phone: '13800138008', status: 1, createdAt: '2024-04-14 09:40:00' },
+  { id: 9,  name: '郑十一', workNo: '2009001122', role: '实验室管理员', dept: '部门1/一组', gender: 'female', phone: '13900139001', status: 1, createdAt: '2024-05-06 17:22:00' },
+  { id: 10, name: '陈十二', workNo: '2009002233', role: '普通教师',     dept: '部门1/二组', gender: 'male',   phone: '13900139002', status: 1, createdAt: '2024-05-19 12:35:00' },
+  { id: 11, name: '林小美', workNo: '2009003344', role: '学生',         dept: '部门2/一组', gender: 'female', phone: '13900139003', status: 1, createdAt: '2024-06-03 08:15:00' },
+  { id: 12, name: '黄建国', workNo: '2009004455', role: '设备管理员',   dept: '部门2/二组', gender: 'male',   phone: '13900139004', status: 0, createdAt: '2024-06-21 15:48:00' },
+  { id: 13, name: '刘芳',   workNo: '2009005566', role: '普通教师',     dept: '部门1/一组', gender: 'female', phone: '13900139005', status: 1, createdAt: '2024-07-08 10:20:00' },
+  { id: 14, name: '徐明',   workNo: '2009006677', role: '学生',         dept: '部门1/二组', gender: 'male',   phone: '13900139006', status: 1, createdAt: '2024-07-25 14:33:00' },
+  { id: 15, name: '朱丽华', workNo: '2009007788', role: '实验室管理员', dept: '部门2/一组', gender: 'female', phone: '13900139007', status: 1, createdAt: '2024-08-09 09:05:00' },
+  { id: 16, name: '马超',   workNo: '2009008899', role: '普通教师',     dept: '部门2/二组', gender: 'male',   phone: '13900139008', status: 0, createdAt: '2024-08-27 11:17:00' },
+  { id: 17, name: '胡雪梅', workNo: '2010001112', role: '学生',         dept: '部门1/一组', gender: 'female', phone: '13700137001', status: 1, createdAt: '2024-09-12 16:42:00' },
+  { id: 18, name: '高峰',   workNo: '2010002223', role: '设备管理员',   dept: '部门1/二组', gender: 'male',   phone: '13700137002', status: 1, createdAt: '2024-10-04 08:58:00' },
+  { id: 19, name: '邓志远', workNo: '2010003334', role: '普通教师',     dept: '部门2/一组', gender: 'male',   phone: '13700137003', status: 1, createdAt: '2024-11-18 13:25:00' },
+  { id: 20, name: '曹阳',   workNo: '2010004445', role: '学生',         dept: '部门2/二组', gender: 'female', phone: '13700137004', status: 1, createdAt: '2024-12-01 10:00:00' }
+]
+
+export function getUserList() {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      resolve({ code: 200, data: { list: [...mockUsers], total: 400 } })
+    }, 300)
+  })
+}
+
+export function getUserDetail(id) {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      const user = mockUsers.find(u => u.id === Number(id)) || {}
+      resolve({ code: 200, data: user })
+    }, 200)
+  })
+}
+
+export function saveUser() {
+  return new Promise(resolve => {
+    setTimeout(() => resolve({ code: 200, message: '保存成功' }), 400)
+  })
+}
+
+export const USER_ROLES = ['实验室管理员', '设备管理员', '普通教师', '学生']
+export const USER_DEPTS = ['部门1', '部门1/一组', '部门1/二组', '部门2', '部门2/一组', '部门2/二组']

+ 0 - 0
src/assets/.gitkeep


+ 1 - 0
src/components/.gitkeep

@@ -0,0 +1 @@
+<!-- 公共组件目录(待扩展) -->

+ 31 - 0
src/components/QrCode.vue

@@ -0,0 +1,31 @@
+<template>
+  <canvas ref="canvas"></canvas>
+</template>
+
+<script>
+import QRCode from 'qrcode'
+
+export default {
+  name: 'QrCode',
+  props: {
+    value: { type: String, default: '' },
+    size: { type: Number, default: 52 }
+  },
+  mounted() {
+    this.render()
+  },
+  watch: {
+    value: 'render'
+  },
+  methods: {
+    // 使用 qrcode 库在 canvas 上生成二维码
+    render() {
+      QRCode.toCanvas(this.$refs.canvas, this.value || 'device', {
+        width: this.size,
+        margin: 1,
+        color: { dark: '#000000', light: '#ffffff' }
+      }).catch(() => {})
+    }
+  }
+}
+</script>

+ 87 - 0
src/components/charts/BarChart.vue

@@ -0,0 +1,87 @@
+<template>
+  <div ref="chartEl" class="chart-container"></div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+
+export default {
+  name: 'BarChart',
+  data() {
+    return {
+      chart: null
+    }
+  },
+  mounted() {
+    this.initChart()
+    window.addEventListener('resize', this.handleResize)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize)
+    if (this.chart) {
+      this.chart.dispose()
+    }
+  },
+  methods: {
+    // 初始化柱状图:展示各品类销售占比
+    initChart() {
+      const categories = ['手机', '电脑', '平板', '耳机', '手表', '配件']
+      const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#b37feb']
+      const values = [98000, 75000, 43000, 62000, 38000, 25000]
+
+      this.chart = echarts.init(this.$refs.chartEl)
+      this.chart.setOption({
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' },
+          formatter: params => {
+            const { name, value } = params[0]
+            return `${name}<br/>销售额:¥${value.toLocaleString()}`
+          }
+        },
+        grid: {
+          left: '3%',
+          right: '4%',
+          bottom: '3%',
+          top: '20px',
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: categories,
+          axisLine: { lineStyle: { color: '#e6e6e6' } }
+        },
+        yAxis: {
+          type: 'value',
+          axisLabel: {
+            formatter: val => `¥${(val / 10000).toFixed(0)}万`
+          },
+          splitLine: { lineStyle: { type: 'dashed' } }
+        },
+        series: [
+          {
+            name: '销售额',
+            type: 'bar',
+            barMaxWidth: 48,
+            data: values.map((v, i) => ({
+              value: v,
+              itemStyle: { color: colors[i], borderRadius: [4, 4, 0, 0] }
+            }))
+          }
+        ]
+      })
+    },
+
+    handleResize() {
+      this.chart && this.chart.resize()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.chart-container {
+  width: 100%;
+  height: 300px;
+}
+</style>

+ 105 - 0
src/components/charts/LineChart.vue

@@ -0,0 +1,105 @@
+<template>
+  <div ref="chartEl" class="chart-container"></div>
+</template>
+
+<script>
+// 引入 ECharts 核心模块
+import * as echarts from 'echarts'
+
+export default {
+  name: 'LineChart',
+  data() {
+    return {
+      chart: null
+    }
+  },
+  mounted() {
+    this.initChart()
+    // 监听窗口 resize,让图表自适应容器宽度
+    window.addEventListener('resize', this.handleResize)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize)
+    if (this.chart) {
+      this.chart.dispose()
+    }
+  },
+  methods: {
+    // 初始化折线图:展示近7天销售额趋势
+    initChart() {
+      // 动态生成近7天日期标签
+      const dates = []
+      for (let i = 6; i >= 0; i--) {
+        const d = new Date()
+        d.setDate(d.getDate() - i)
+        dates.push(`${d.getMonth() + 1}/${d.getDate()}`)
+      }
+
+      this.chart = echarts.init(this.$refs.chartEl)
+      this.chart.setOption({
+        tooltip: {
+          trigger: 'axis',
+          formatter: params => {
+            const { name, value } = params[0]
+            return `${name}<br/>销售额:¥${value.toLocaleString()}`
+          }
+        },
+        legend: {
+          data: ['销售额'],
+          top: 0
+        },
+        grid: {
+          left: '3%',
+          right: '4%',
+          bottom: '3%',
+          top: '40px',
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: dates,
+          boundaryGap: false,
+          axisLine: { lineStyle: { color: '#e6e6e6' } }
+        },
+        yAxis: {
+          type: 'value',
+          axisLabel: {
+            formatter: val => `¥${(val / 10000).toFixed(0)}万`
+          },
+          splitLine: { lineStyle: { type: 'dashed' } }
+        },
+        series: [
+          {
+            name: '销售额',
+            type: 'line',
+            smooth: true,
+            // 硬编码 mock 数据
+            data: [42000, 58000, 36000, 75000, 89000, 62000, 94000],
+            areaStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: 'rgba(64,158,255,0.35)' },
+                { offset: 1, color: 'rgba(64,158,255,0.02)' }
+              ])
+            },
+            lineStyle: { color: '#409EFF', width: 2 },
+            itemStyle: { color: '#409EFF' },
+            symbol: 'circle',
+            symbolSize: 6
+          }
+        ]
+      })
+    },
+
+    handleResize() {
+      this.chart && this.chart.resize()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.chart-container {
+  width: 100%;
+  height: 300px;
+}
+</style>

+ 110 - 0
src/layout/components/AppHeader.vue

@@ -0,0 +1,110 @@
+<template>
+  <div class="header-wrapper">
+    <!-- 左侧:折叠按钮 + 面包屑 -->
+    <div class="header-left">
+      <i
+        :class="isCollapsed ? 'el-icon-s-unfold' : 'el-icon-s-fold'"
+        class="toggle-btn"
+        @click="$emit('toggle-sidebar')"
+      ></i>
+      <el-breadcrumb separator="/">
+        <el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
+        <el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item>
+      </el-breadcrumb>
+    </div>
+
+    <!-- 右侧:用户信息下拉 -->
+    <div class="header-right">
+      <el-dropdown trigger="click">
+        <span class="user-info">
+          <el-avatar :size="32" icon="el-icon-user-solid" />
+          <span class="username">{{ nickname }}</span>
+          <i class="el-icon-arrow-down el-icon--right"></i>
+        </span>
+        <el-dropdown-menu slot="dropdown">
+          <el-dropdown-item icon="el-icon-switch-button" @click.native="logout">
+            退出登录
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'AppHeader',
+  props: {
+    isCollapsed: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    currentTitle() {
+      return this.$route.meta && this.$route.meta.title
+    },
+    nickname() {
+      const userInfo = this.$store.getters.userInfo
+      return (userInfo && userInfo.nickname) || '管理员'
+    }
+  },
+  methods: {
+    logout() {
+      this.$confirm('确认退出登录?', '提示', {
+        confirmButtonText: '确认',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.$store.dispatch('user/logout')
+        this.$router.push('/login')
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.header-wrapper {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 100%;
+  padding: 0 16px;
+
+  .header-left {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+
+    .toggle-btn {
+      font-size: 20px;
+      cursor: pointer;
+      color: #606266;
+      transition: color 0.2s;
+
+      &:hover {
+        color: #409eff;
+      }
+    }
+  }
+
+  .header-right {
+    .user-info {
+      display: flex;
+      align-items: center;
+      cursor: pointer;
+      color: #606266;
+      font-size: 14px;
+
+      .username {
+        margin: 0 6px;
+      }
+
+      &:hover {
+        color: #409eff;
+      }
+    }
+  }
+}
+</style>

+ 73 - 0
src/layout/components/Sidebar.vue

@@ -0,0 +1,73 @@
+<template>
+  <el-menu
+    :default-active="activeMenu"
+    :collapse="isCollapsed"
+    :collapse-transition="false"
+    background-color="#304156"
+    text-color="#bfcbd9"
+    active-text-color="#409EFF"
+    router
+    class="sidebar-menu"
+  >
+    <el-menu-item
+      v-for="item in menuItems"
+      :key="item.path"
+      :index="item.path"
+    >
+      <i :class="item.icon"></i>
+      <span slot="title">{{ item.title }}</span>
+    </el-menu-item>
+  </el-menu>
+</template>
+
+<script>
+export default {
+  name: 'Sidebar',
+  props: {
+    isCollapsed: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      menuItems: [
+        { path: '/role',   title: '角色管理', icon: 'el-icon-s-check' },
+        { path: '/user',   title: '用户管理', icon: 'el-icon-user' },
+        { path: '/device', title: '设备管理', icon: 'el-icon-cpu' },
+        { path: '/usage',  title: '使用记录', icon: 'el-icon-tickets' },
+        { path: '/menu',   title: '菜单管理', icon: 'el-icon-menu' }
+      ]
+    }
+  },
+  computed: {
+    // 子页面(form)访问时仍高亮父级菜单
+    activeMenu() {
+      const path = this.$route.path
+      if (path.startsWith('/role'))   return '/role'
+      if (path.startsWith('/device')) return '/device'
+      if (path.startsWith('/user'))   return '/user'
+      return path
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.sidebar-menu {
+  border-right: none;
+  width: 100% !important;
+
+  // 折叠状态下居中图标
+  &.el-menu--collapse {
+    ::v-deep .el-menu-item {
+      padding: 0 !important;
+      text-align: center;
+
+      i {
+        margin-right: 0;
+      }
+    }
+  }
+}
+</style>

+ 104 - 0
src/layout/index.vue

@@ -0,0 +1,104 @@
+<template>
+  <el-container class="layout-container">
+    <!-- 左侧侧边栏 -->
+    <el-aside :width="isCollapsed ? '64px' : '210px'" class="sidebar">
+      <div class="logo">
+        <i class="el-icon-cpu"></i>
+        <span v-show="!isCollapsed" class="logo-title">设备管理系统</span>
+      </div>
+      <Sidebar :is-collapsed="isCollapsed" />
+    </el-aside>
+
+    <!-- 右侧主体 -->
+    <el-container class="main-container">
+      <!-- 顶部 Header -->
+      <el-header class="app-header" height="60px">
+        <AppHeader :is-collapsed="isCollapsed" @toggle-sidebar="toggleSidebar" />
+      </el-header>
+
+      <!-- 内容区域 -->
+      <el-main class="app-main">
+        <router-view />
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<script>
+import Sidebar from './components/Sidebar.vue'
+import AppHeader from './components/AppHeader.vue'
+
+export default {
+  name: 'Layout',
+  components: { Sidebar, AppHeader },
+  data() {
+    return {
+      isCollapsed: false
+    }
+  },
+  methods: {
+    toggleSidebar() {
+      this.isCollapsed = !this.isCollapsed
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import '@/styles/variables.scss';
+
+.layout-container {
+  height: 100vh;
+  overflow: hidden;
+
+  .sidebar {
+    background-color: $sidebar-bg;
+    transition: width 0.3s ease;
+    overflow: hidden;
+    flex-shrink: 0;
+
+    .logo {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: $header-height;
+      color: #fff;
+      font-size: 16px;
+      font-weight: bold;
+      white-space: nowrap;
+      overflow: hidden;
+      background-color: #263445;
+      padding: 0 16px;
+
+      i {
+        font-size: 22px;
+        flex-shrink: 0;
+      }
+
+      .logo-title {
+        margin-left: 10px;
+        font-size: 14px;
+        letter-spacing: 0.5px;
+      }
+    }
+  }
+
+  .main-container {
+    overflow: hidden;
+    flex: 1;
+
+    .app-header {
+      padding: 0;
+      background-color: #fff;
+      border-bottom: 1px solid #e6e6e6;
+      box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
+    }
+
+    .app-main {
+      padding: 20px;
+      overflow-y: auto;
+      background-color: #f0f2f5;
+    }
+  }
+}
+</style>

+ 21 - 0
src/main.js

@@ -0,0 +1,21 @@
+import Vue from 'vue'
+import App from './App.vue'
+import router from './router'
+import store from './store'
+
+// 完整引入 Element UI
+import ElementUI from 'element-ui'
+import 'element-ui/lib/theme-chalk/index.css'
+
+// 全局样式
+import './styles/index.scss'
+
+Vue.config.productionTip = false
+
+Vue.use(ElementUI)
+
+new Vue({
+  router,
+  store,
+  render: h => h(App)
+}).$mount('#app')

+ 106 - 0
src/router/index.js

@@ -0,0 +1,106 @@
+import Vue from 'vue'
+import VueRouter from 'vue-router'
+import Layout from '@/layout/index.vue'
+import store from '@/store'
+
+Vue.use(VueRouter)
+
+const routes = [
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/login/index.vue')
+  },
+  {
+    path: '/',
+    component: Layout,
+    redirect: '/role',
+    children: [
+      {
+        path: 'role',
+        name: 'Role',
+        component: () => import('@/views/role/index.vue'),
+        meta: { title: '角色管理', icon: 'el-icon-s-check' }
+      },
+      {
+        path: 'role/permission',
+        name: 'RolePermission',
+        component: () => import('@/views/role/permission.vue'),
+        meta: { title: '权限配置', hidden: true }
+      },
+      {
+        path: 'user',
+        name: 'User',
+        component: () => import('@/views/user/index.vue'),
+        meta: { title: '用户管理', icon: 'el-icon-user' }
+      },
+      {
+        path: 'user/form',
+        name: 'UserForm',
+        component: () => import('@/views/user/form.vue'),
+        meta: { title: '用户表单', hidden: true }
+      },
+      {
+        path: 'device',
+        name: 'Device',
+        component: () => import('@/views/device/index.vue'),
+        meta: { title: '设备管理', icon: 'el-icon-cpu' }
+      },
+      {
+        // 新增/编辑/详情 共用同一个表单页,hidden 不出现在侧边栏
+        path: 'device/form',
+        name: 'DeviceForm',
+        component: () => import('@/views/device/form.vue'),
+        meta: { title: '设备表单', icon: 'el-icon-cpu', hidden: true }
+      },
+      {
+        path: 'usage',
+        name: 'Usage',
+        component: () => import('@/views/usage/index.vue'),
+        meta: { title: '使用记录', icon: 'el-icon-tickets' }
+      },
+      {
+        path: 'menu',
+        name: 'Menu',
+        component: () => import('@/views/menu/index.vue'),
+        meta: { title: '菜单管理', icon: 'el-icon-menu' }
+      }
+    ]
+  },
+  // 未匹配路由重定向到首页
+  {
+    path: '*',
+    redirect: '/role'
+  }
+]
+
+const router = new VueRouter({
+  mode: 'history',
+  base: process.env.BASE_URL,
+  routes
+})
+
+// 不需要登录即可访问的白名单路由
+const whiteList = ['/login']
+
+// 路由守卫:未登录时拦截跳转到登录页
+router.beforeEach((to, from, next) => {
+  const token = store.getters.token
+  if (token) {
+    if (to.path === '/login') {
+      // 已登录时访问登录页,直接跳转首页
+      next({ path: '/role' })
+    } else {
+      next()
+    }
+  } else {
+    if (whiteList.includes(to.path)) {
+      next()
+    } else {
+      // 记录来源页,登录后可跳回
+      next(`/login?redirect=${to.path}`)
+    }
+  }
+})
+
+export default router

+ 16 - 0
src/store/index.js

@@ -0,0 +1,16 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import user from './modules/user'
+
+Vue.use(Vuex)
+
+export default new Vuex.Store({
+  // 根级 getters,方便全局访问无需加模块前缀
+  getters: {
+    token: state => state.user.token,
+    userInfo: state => state.user.userInfo
+  },
+  modules: {
+    user
+  }
+})

+ 52 - 0
src/store/modules/user.js

@@ -0,0 +1,52 @@
+import { login } from '@/api/auth'
+
+const TOKEN_KEY = 'device_mgr_token'
+
+const state = {
+  // 优先从 localStorage 恢复,实现页面刷新后保持登录态
+  token: localStorage.getItem(TOKEN_KEY) || '',
+  userInfo: {}
+}
+
+const mutations = {
+  SET_TOKEN(state, token) {
+    state.token = token
+    localStorage.setItem(TOKEN_KEY, token)
+  },
+  SET_USER_INFO(state, userInfo) {
+    state.userInfo = userInfo
+  },
+  CLEAR_AUTH(state) {
+    state.token = ''
+    state.userInfo = {}
+    localStorage.removeItem(TOKEN_KEY)
+  }
+}
+
+const actions = {
+  // 登录:调用接口,成功后保存 token 和用户信息
+  async login({ commit }, loginData) {
+    const res = await login(loginData)
+    commit('SET_TOKEN', res.data.token)
+    // 实际 API 返回 { token, userId, userName, nickName }
+    commit('SET_USER_INFO', {
+      id: res.data.userId,
+      username: res.data.userName,
+      nickname: res.data.nickName,
+      avatar: res.data.avatar || ''
+    })
+    return res
+  },
+
+  // 退出登录:清除认证状态
+  logout({ commit }) {
+    commit('CLEAR_AUTH')
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 35 - 0
src/styles/index.scss

@@ -0,0 +1,35 @@
+@import './variables.scss';
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+html,
+body {
+  height: 100%;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+  font-size: 14px;
+  color: #303133;
+  background-color: #f0f2f5;
+}
+
+#app {
+  height: 100%;
+}
+
+// 滚动条美化
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-webkit-scrollbar-thumb {
+  background: #c0c4cc;
+  border-radius: 3px;
+}
+
+::-webkit-scrollbar-track {
+  background: transparent;
+}

+ 16 - 0
src/styles/variables.scss

@@ -0,0 +1,16 @@
+// 颜色变量
+$primary-color: #409EFF;
+$success-color: #67C23A;
+$warning-color: #E6A23C;
+$danger-color: #F56C6C;
+$info-color: #909399;
+
+// 布局变量
+$sidebar-width: 210px;
+$sidebar-collapsed-width: 64px;
+$header-height: 60px;
+
+// 背景色
+$sidebar-bg: #304156;
+$sidebar-text-color: #bfcbd9;
+$sidebar-active-color: #409EFF;

+ 1 - 0
src/utils/index.js

@@ -0,0 +1 @@
+// 工具函数(待扩展)

+ 51 - 0
src/utils/request.js

@@ -0,0 +1,51 @@
+import axios from 'axios'
+import { Message } from 'element-ui'
+import store from '@/store'
+import router from '@/router'
+
+const service = axios.create({
+  baseURL: 'http://192.168.1.20:8089/api',
+  timeout: 15000
+})
+
+// 请求拦截器:自动携带 Bearer token
+service.interceptors.request.use(
+  config => {
+    const token = store.getters.token
+    if (token) {
+      config.headers['Authorization'] = `Bearer ${token}`
+    }
+    return config
+  },
+  error => Promise.reject(error)
+)
+
+// 响应拦截器:统一处理业务错误与 HTTP 错误
+service.interceptors.response.use(
+  response => {
+    const res = response.data
+    // 业务码非 200 视为失败
+    if (res && res.code !== undefined && res.code !== 200) {
+      if (res.code === 401) {
+        store.dispatch('user/logout')
+        router.push('/login')
+        return Promise.reject(new Error(res.message || '登录已过期,请重新登录'))
+      }
+      Message.error(res.message || '操作失败')
+      return Promise.reject(new Error(res.message || '操作失败'))
+    }
+    return res
+  },
+  error => {
+    const status = error.response?.status
+    if (status === 401) {
+      store.dispatch('user/logout')
+      router.push('/login')
+    }
+    const msg = error.response?.data?.message || error.message || '请求失败,请稍后重试'
+    Message.error(msg)
+    return Promise.reject(error)
+  }
+)
+
+export default service

+ 195 - 0
src/views/dashboard/index.vue

@@ -0,0 +1,195 @@
+<template>
+  <div class="dashboard">
+    <!-- 页面标题 -->
+    <div class="page-title">仪表盘</div>
+
+    <!-- 统计卡片 -->
+    <el-row :gutter="16" class="stat-cards">
+      <el-col
+        v-for="card in statCards"
+        :key="card.key"
+        :xs="24"
+        :sm="12"
+        :lg="6"
+      >
+        <el-card class="stat-card" shadow="hover">
+          <div class="stat-card-inner">
+            <div class="stat-info">
+              <div class="stat-label">{{ card.label }}</div>
+              <div class="stat-value">{{ card.value }}</div>
+              <div class="stat-trend" :class="card.up ? 'up' : 'down'">
+                <i :class="card.up ? 'el-icon-top' : 'el-icon-bottom'"></i>
+                较昨日 {{ card.change }}
+              </div>
+            </div>
+            <div class="stat-icon" :style="{ background: card.color }">
+              <i :class="card.icon"></i>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 图表区域 -->
+    <el-row :gutter="16" class="chart-row">
+      <!-- 折线图:近7天销售额趋势 -->
+      <el-col :xs="24" :lg="16">
+        <el-card shadow="hover">
+          <div slot="header" class="card-header">
+            <span>近7天销售额趋势</span>
+          </div>
+          <LineChart />
+        </el-card>
+      </el-col>
+
+      <!-- 柱状图:各品类销售占比 -->
+      <el-col :xs="24" :lg="8">
+        <el-card shadow="hover">
+          <div slot="header" class="card-header">
+            <span>各品类销售占比</span>
+          </div>
+          <BarChart />
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import LineChart from '@/components/charts/LineChart.vue'
+import BarChart from '@/components/charts/BarChart.vue'
+
+export default {
+  name: 'Dashboard',
+  components: { LineChart, BarChart },
+  data() {
+    return {
+      // 顶部统计卡片 mock 数据
+      statCards: [
+        {
+          key: 'sales',
+          label: '今日销售额',
+          value: '¥128,560',
+          change: '12.5%',
+          up: true,
+          icon: 'el-icon-money',
+          color: 'linear-gradient(135deg, #409EFF, #2980f0)'
+        },
+        {
+          key: 'orders',
+          label: '今日订单数',
+          value: '1,284',
+          change: '8.3%',
+          up: true,
+          icon: 'el-icon-s-order',
+          color: 'linear-gradient(135deg, #67C23A, #4da826)'
+        },
+        {
+          key: 'users',
+          label: '用户总数',
+          value: '98,632',
+          change: '3.1%',
+          up: true,
+          icon: 'el-icon-user',
+          color: 'linear-gradient(135deg, #E6A23C, #d4892a)'
+        },
+        {
+          key: 'newUsers',
+          label: '今日新增用户',
+          value: '156',
+          change: '5.2%',
+          up: false,
+          icon: 'el-icon-s-custom',
+          color: 'linear-gradient(135deg, #F56C6C, #e34a4a)'
+        }
+      ]
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.dashboard {
+  .page-title {
+    font-size: 18px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 16px;
+  }
+
+  .stat-cards {
+    margin-bottom: 16px;
+
+    .stat-card {
+      margin-bottom: 16px;
+      border-radius: 8px;
+      cursor: default;
+
+      ::v-deep .el-card__body {
+        padding: 20px;
+      }
+
+      .stat-card-inner {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+
+        .stat-info {
+          .stat-label {
+            font-size: 13px;
+            color: #909399;
+            margin-bottom: 8px;
+          }
+
+          .stat-value {
+            font-size: 26px;
+            font-weight: 700;
+            color: #303133;
+            margin-bottom: 8px;
+            line-height: 1;
+          }
+
+          .stat-trend {
+            font-size: 12px;
+
+            &.up {
+              color: #67C23A;
+            }
+
+            &.down {
+              color: #F56C6C;
+            }
+          }
+        }
+
+        .stat-icon {
+          width: 56px;
+          height: 56px;
+          border-radius: 12px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          flex-shrink: 0;
+
+          i {
+            font-size: 26px;
+            color: #fff;
+          }
+        }
+      }
+    }
+  }
+
+  .chart-row {
+    .el-col {
+      margin-bottom: 16px;
+    }
+
+    .card-header {
+      font-size: 15px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+}
+</style>

+ 356 - 0
src/views/device/form.vue

@@ -0,0 +1,356 @@
+<template>
+  <div class="device-form">
+    <!-- 页面标题 + 返回 -->
+    <div class="page-header">
+      <el-button icon="el-icon-arrow-left" size="small" @click="$router.push('/device')">返回列表</el-button>
+      <span class="page-title">{{ pageTitle }}</span>
+    </div>
+
+    <el-card shadow="never">
+      <el-form
+        ref="deviceForm"
+        :model="form"
+        :rules="isView ? {} : formRules"
+        :disabled="isView"
+        label-width="90px"
+        size="small"
+      >
+        <!-- 第一行:设备编号 / 设备名称 / 状态 -->
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="设备编号" prop="deviceNo">
+              <el-input v-model="form.deviceNo" placeholder="请输入设备编号" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="设备名称" prop="name">
+              <el-input v-model="form.name" placeholder="请输入设备名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="状态" prop="status">
+              <el-select v-model="form.status" placeholder="请选择状态" style="width:100%">
+                <el-option v-for="s in statuses" :key="s" :label="s" :value="s" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 第二行:规格型号 / 分类(宽) -->
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="规格型号" prop="spec">
+              <el-input v-model="form.spec" placeholder="请输入规格型号" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="16">
+            <el-form-item label="分类" prop="category">
+              <el-row :gutter="8">
+                <el-col :span="18">
+                  <el-input v-model="form.categoryPath" readonly placeholder="请选择设备分类" />
+                </el-col>
+                <el-col :span="6">
+                  <el-button style="width:100%" :disabled="isView" @click="tip('选择分类')">选择分类</el-button>
+                </el-col>
+              </el-row>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 第三行:所属单位 / 实验室 / 楼栋楼层 -->
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="所属单位" prop="dept">
+              <el-select v-model="form.dept" placeholder="请选择所属单位" style="width:100%">
+                <el-option v-for="d in depts" :key="d" :label="d" :value="d" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="实验室" prop="lab">
+              <el-input v-model="form.lab" placeholder="请输入实验室名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="楼栋楼层" prop="building">
+              <el-input v-model="form.building" placeholder="如:A栋3楼" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 第四行:管理部门 / 设备管理员 / 价格 -->
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="管理部门" prop="manageDept">
+              <el-input v-model="form.manageDept" placeholder="请输入管理部门" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="设备管理员" prop="manager">
+              <el-input v-model="form.manager" placeholder="请输入管理员姓名" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="价格(元)" prop="price">
+              <el-input-number
+                v-model="form.price"
+                :min="0"
+                :precision="2"
+                :step="1000"
+                style="width:100%"
+                placeholder="请输入价格"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 第五行:品牌 / 生产厂家 / 产地 -->
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="品牌" prop="brand">
+              <el-input v-model="form.brand" placeholder="请输入品牌名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="生产厂家" prop="manufacturer">
+              <el-input v-model="form.manufacturer" placeholder="请输入生产厂家" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="产地" prop="origin">
+              <el-input v-model="form.origin" placeholder="请输入产地" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 第六行:单位 / 类型 / 入库日期 -->
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="单位" prop="unit">
+              <el-input v-model="form.unit" placeholder="如:台、套、件" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="类型" prop="type">
+              <el-select v-model="form.type" placeholder="请选择类型" style="width:100%">
+                <el-option label="固定资产" value="固定资产" />
+                <el-option label="低值易耗品" value="低值易耗品" />
+                <el-option label="耗材" value="耗材" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="入库日期" prop="inboundDate">
+              <el-date-picker
+                v-model="form.inboundDate"
+                type="date"
+                placeholder="选择入库日期"
+                value-format="yyyy-MM-dd"
+                style="width:100%"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 设备用途(全宽) -->
+        <el-form-item label="设备用途" prop="purpose">
+          <el-input
+            v-model="form.purpose"
+            type="textarea"
+            :rows="2"
+            placeholder="请描述设备主要用途"
+          />
+        </el-form-item>
+
+        <!-- 图片 + 说明书上传 -->
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="图片">
+              <el-upload
+                action="#"
+                list-type="picture-card"
+                :auto-upload="false"
+                :disabled="isView"
+                :file-list="form.images"
+                accept="image/*"
+              >
+                <i class="el-icon-plus" />
+                <div slot="tip" class="el-upload__tip">支持 jpg/png,单张不超过 5MB</div>
+              </el-upload>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="说明书">
+              <el-upload
+                action="#"
+                :auto-upload="false"
+                :disabled="isView"
+                :file-list="form.manuals"
+                accept=".pdf,.doc,.docx"
+              >
+                <el-button size="small" icon="el-icon-upload2" :disabled="isView">点击上传</el-button>
+                <div slot="tip" class="el-upload__tip">支持 PDF/Word,不超过 20MB</div>
+              </el-upload>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 技术参数(全宽) -->
+        <el-form-item label="技术参数" prop="techParams">
+          <el-input
+            v-model="form.techParams"
+            type="textarea"
+            :rows="4"
+            placeholder="请填写设备技术参数,如温控范围、功率、精度等"
+          />
+        </el-form-item>
+
+        <!-- 备注(全宽) -->
+        <el-form-item label="备注" prop="remark">
+          <el-input
+            v-model="form.remark"
+            type="textarea"
+            :rows="3"
+            placeholder="其他需要说明的信息"
+          />
+        </el-form-item>
+      </el-form>
+
+      <!-- 底部操作按钮 -->
+      <div v-if="!isView" class="form-footer">
+        <el-button @click="$router.push('/device')">取 消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleSave">保 存</el-button>
+      </div>
+      <div v-else class="form-footer">
+        <el-button type="primary" @click="$router.push(`/device/form?id=${form.id}&mode=edit`)">
+          编 辑
+        </el-button>
+        <el-button @click="$router.push('/device')">返 回</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getDeviceDetail, saveDevice, DEVICE_DEPTS, DEVICE_STATUSES } from '@/api/device'
+
+const DEFAULT_FORM = () => ({
+  id: null,
+  deviceNo: '',
+  name: '',
+  status: '空闲',
+  spec: '',
+  categoryPath: '',
+  dept: '',
+  lab: '',
+  building: '',
+  manageDept: '',
+  manager: '',
+  price: 0,
+  brand: '',
+  manufacturer: '',
+  origin: '中国',
+  unit: '台',
+  type: '固定资产',
+  inboundDate: '',
+  purpose: '',
+  techParams: '',
+  remark: '',
+  images: [],
+  manuals: []
+})
+
+export default {
+  name: 'DeviceForm',
+  data() {
+    return {
+      saving: false,
+      form: DEFAULT_FORM(),
+      depts: DEVICE_DEPTS,
+      statuses: DEVICE_STATUSES,
+      formRules: {
+        deviceNo: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
+        name:     [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
+        status:   [{ required: true, message: '请选择状态',     trigger: 'change' }],
+        dept:     [{ required: true, message: '请选择所属单位', trigger: 'change' }]
+      }
+    }
+  },
+  computed: {
+    mode() {
+      return this.$route.query.mode || 'add'
+    },
+    isView() {
+      return this.mode === 'view'
+    },
+    pageTitle() {
+      return { add: '新增设备', edit: '编辑设备', view: '设备详情' }[this.mode] || '新增设备'
+    },
+    deviceId() {
+      return this.$route.query.id
+    }
+  },
+  created() {
+    if (this.deviceId) {
+      this.loadDetail()
+    }
+  },
+  methods: {
+    async loadDetail() {
+      const res = await getDeviceDetail(this.deviceId)
+      const d = res.data
+      this.form = {
+        ...DEFAULT_FORM(),
+        ...d,
+        categoryPath: d.category || '',
+        images: [],
+        manuals: []
+      }
+    },
+
+    handleSave() {
+      this.$refs.deviceForm.validate(async valid => {
+        if (!valid) return
+        this.saving = true
+        try {
+          await saveDevice(this.form)
+          this.$message.success(this.mode === 'add' ? '新增成功' : '保存成功')
+          this.$router.push('/device')
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+
+    tip(name) {
+      this.$message.info(`${name}功能待对接后端实现`)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.device-form {
+  .page-header {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    margin-bottom: 16px;
+
+    .page-title {
+      font-size: 16px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  .form-footer {
+    display: flex;
+    justify-content: center;
+    gap: 16px;
+    padding-top: 20px;
+    border-top: 1px solid #f0f0f0;
+    margin-top: 8px;
+  }
+}
+</style>

+ 258 - 0
src/views/device/index.vue

@@ -0,0 +1,258 @@
+<template>
+  <div class="device-list">
+    <!-- 搜索区域 -->
+    <el-card shadow="never" class="search-card">
+      <el-form :model="searchForm" label-width="80px" size="small">
+        <el-row :gutter="16">
+          <el-col :span="6">
+            <el-form-item label="设备名称">
+              <el-input v-model="searchForm.name" placeholder="请输入设备名称" clearable />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="分类">
+              <el-select v-model="searchForm.category" placeholder="请选择设备分类" clearable style="width:100%">
+                <el-option v-for="c in categories" :key="c" :label="c" :value="c" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="实验室名称">
+              <el-input v-model="searchForm.labName" placeholder="请输入实验室名称" clearable />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="实验室编号">
+              <el-input v-model="searchForm.labNo" placeholder="请输入实验室编号" clearable />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="16">
+          <el-col :span="6">
+            <el-form-item label="状态">
+              <el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width:100%">
+                <el-option v-for="s in statuses" :key="s" :label="s" :value="s" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="所属部门">
+              <el-select v-model="searchForm.dept" placeholder="请选择部门" clearable style="width:100%">
+                <el-option v-for="d in depts" :key="d" :label="d" :value="d" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label-width="0">
+              <el-button type="primary"  icon="el-icon-search"    size="small" @click="handleSearch">查询</el-button>
+              <el-button               icon="el-icon-refresh"   size="small" @click="handleReset">重置</el-button>
+              <el-button               icon="el-icon-upload2"   size="small" @click="tip('导出excel')">导出excel</el-button>
+              <el-button               icon="el-icon-download"  size="small" @click="tip('批量导入')">批量导入</el-button>
+              <el-button               icon="el-icon-receiving" size="small" @click="handleDownloadQr">批量下载二维码</el-button>
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+    </el-card>
+
+    <!-- 表格区域 -->
+    <el-card shadow="never" class="table-card">
+      <!-- 新增按钮 -->
+      <div class="table-toolbar">
+        <el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增设备</el-button>
+      </div>
+
+      <el-table
+        v-loading="loading"
+        :data="deviceList"
+        border
+        size="small"
+        style="width:100%"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column type="selection" width="45" align="center" />
+        <el-table-column label="编号"       prop="deviceNo"  width="115" />
+        <el-table-column label="一级分类"   prop="category"  min-width="120" show-overflow-tooltip />
+        <el-table-column label="设备名称"   prop="name"      min-width="110" show-overflow-tooltip />
+        <el-table-column label="型号"       prop="model"     min-width="110" show-overflow-tooltip />
+        <el-table-column label="状态"       prop="status"    width="80"  align="center">
+          <template slot-scope="{ row }">
+            <el-tag :type="statusType(row.status)" size="mini" effect="light">
+              {{ row.status }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="设备管理员" prop="manager"   min-width="140" show-overflow-tooltip />
+        <el-table-column label="实验室名称" prop="labName"   min-width="140" show-overflow-tooltip />
+        <el-table-column label="二维码"     width="72"       align="center">
+          <template slot-scope="{ row }">
+            <QrCode :value="row.deviceNo" :size="52" />
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" align="center" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button type="text" size="mini" @click="handleEdit(row)">编辑</el-button>
+            <el-divider direction="vertical" />
+            <el-button type="text" size="mini" @click="handleDetail(row)">详情</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-wrapper">
+        <span class="pagination-info">共{{ total }}条记录 第{{ currentPage }}/{{ totalPages }}页</span>
+        <el-pagination
+          background
+          :current-page="currentPage"
+          :page-sizes="[5, 10, 20]"
+          :page-size="pageSize"
+          :total="total"
+          layout="prev, pager, next, sizes, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getDeviceList, DEVICE_CATEGORIES, DEVICE_DEPTS, DEVICE_STATUSES } from '@/api/device'
+import QrCode from '@/components/QrCode.vue'
+
+export default {
+  name: 'DeviceList',
+  components: { QrCode },
+  data() {
+    return {
+      loading: false,
+      deviceList: [],
+      total: 0,
+      currentPage: 1,
+      pageSize: 5,
+      selectedRows: [],
+
+      searchForm: {
+        name: '',
+        category: '',
+        labName: '',
+        labNo: '',
+        status: '',
+        dept: ''
+      },
+
+      categories: DEVICE_CATEGORIES,
+      depts: DEVICE_DEPTS,
+      statuses: DEVICE_STATUSES
+    }
+  },
+  computed: {
+    totalPages() {
+      return Math.ceil(this.total / this.pageSize) || 1
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      this.loading = true
+      try {
+        const res = await getDeviceList({
+          ...this.searchForm,
+          page: this.currentPage,
+          pageSize: this.pageSize
+        })
+        this.deviceList = res.data.list
+        this.total      = res.data.total
+      } finally {
+        this.loading = false
+      }
+    },
+
+    handleSearch() {
+      this.currentPage = 1
+      this.fetchList()
+    },
+
+    handleReset() {
+      this.searchForm = { name: '', category: '', labName: '', labNo: '', status: '', dept: '' }
+      this.currentPage = 1
+      this.fetchList()
+    },
+
+    handleAdd() {
+      this.$router.push('/device/form')
+    },
+
+    handleEdit(row) {
+      this.$router.push(`/device/form?id=${row.id}&mode=edit`)
+    },
+
+    handleDetail(row) {
+      this.$router.push(`/device/form?id=${row.id}&mode=view`)
+    },
+
+    handleSelectionChange(rows) {
+      this.selectedRows = rows
+    },
+
+    handleDownloadQr() {
+      if (!this.selectedRows.length) {
+        this.$message.warning('请先勾选需要下载二维码的设备')
+        return
+      }
+      this.$message.success(`已选中 ${this.selectedRows.length} 条,二维码下载功能待对接后端`)
+    },
+
+    tip(name) {
+      this.$message.info(`${name}功能待对接后端实现`)
+    },
+
+    handleSizeChange(size) {
+      this.pageSize = size
+      this.currentPage = 1
+      this.fetchList()
+    },
+
+    handleCurrentChange(page) {
+      this.currentPage = page
+      this.fetchList()
+    },
+
+    statusType(status) {
+      const map = { '空闲': 'success', '使用中': 'primary', '维修中': 'warning', '报废': 'danger' }
+      return map[status] || 'info'
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.device-list {
+  .search-card {
+    margin-bottom: 12px;
+    ::v-deep .el-card__body { padding: 16px 16px 4px; }
+  }
+
+  .table-card {
+    .table-toolbar {
+      margin-bottom: 12px;
+    }
+
+    .pagination-wrapper {
+      display: flex;
+      align-items: center;
+      justify-content: flex-end;
+      margin-top: 16px;
+      gap: 16px;
+
+      .pagination-info {
+        font-size: 13px;
+        color: #606266;
+        white-space: nowrap;
+      }
+    }
+  }
+}
+</style>

+ 217 - 0
src/views/login/index.vue

@@ -0,0 +1,217 @@
+<template>
+  <div class="login-container">
+    <div class="login-card">
+      <!-- 页头 Logo -->
+      <div class="login-header">
+        <i class="el-icon-cpu logo-icon"></i>
+        <h2>设备管理系统</h2>
+        <p>后台管理平台</p>
+      </div>
+
+      <!-- 登录表单 -->
+      <el-form
+        ref="loginForm"
+        :model="loginForm"
+        :rules="loginRules"
+        class="login-form"
+        @keyup.enter.native="handleLogin"
+      >
+        <el-form-item prop="username">
+          <el-input
+            v-model="loginForm.username"
+            prefix-icon="el-icon-user"
+            placeholder="用户名"
+            clearable
+          />
+        </el-form-item>
+
+        <el-form-item prop="password">
+          <el-input
+            v-model="loginForm.password"
+            prefix-icon="el-icon-lock"
+            placeholder="密码"
+            type="password"
+            show-password
+            clearable
+          />
+        </el-form-item>
+
+        <el-form-item prop="captcha">
+          <el-row :gutter="10">
+            <el-col :span="16">
+              <el-input
+                v-model="loginForm.captcha"
+                prefix-icon="el-icon-key"
+                placeholder="请输入验证码"
+              />
+            </el-col>
+            <el-col :span="8">
+              <div class="captcha-wrap" @click="loadCaptcha" title="点击刷新验证码">
+                <img
+                  v-if="captchaImage"
+                  :src="captchaImage"
+                  class="captcha-img"
+                  alt="验证码"
+                />
+                <span v-else class="captcha-placeholder">加载中...</span>
+              </div>
+            </el-col>
+          </el-row>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button
+            type="primary"
+            class="login-btn"
+            :loading="loading"
+            @click="handleLogin"
+          >
+            {{ loading ? '登录中...' : '登 录' }}
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script>
+import md5 from 'js-md5'
+import { getCaptcha } from '@/api/auth'
+
+export default {
+  name: 'Login',
+  data() {
+    return {
+      loginForm: {
+        username: '',
+        password: '',
+        captcha: ''
+      },
+      captchaKey: '',    // 后端返回的验证码 key
+      captchaImage: '',  // Base64 验证码图片
+      loading: false,
+      loginRules: {
+        username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+        password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+        captcha:  [{ required: true, message: '请输入验证码', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.loadCaptcha()
+  },
+  methods: {
+    async loadCaptcha() {
+      try {
+        const res = await getCaptcha()
+        this.captchaKey   = res.data.captchaKey
+        this.captchaImage = res.data.captchaImage
+        this.loginForm.captcha = ''
+      } catch {
+        // 加载失败时保持旧图片
+      }
+    },
+
+    handleLogin() {
+      this.$refs.loginForm.validate(async valid => {
+        if (!valid) return
+        this.loading = true
+        try {
+          await this.$store.dispatch('user/login', {
+            username:    this.loginForm.username,
+            password:    md5(this.loginForm.password),
+            captchaKey:  this.captchaKey,
+            captchaCode: this.loginForm.captcha
+          })
+          const redirect = this.$route.query.redirect || '/role'
+          this.$router.push(redirect)
+          this.$message.success('登录成功,欢迎回来!')
+        } catch (err) {
+          // 登录失败后刷新验证码
+          this.loadCaptcha()
+          this.$message.error(err.message || '登录失败,请重试')
+        } finally {
+          this.loading = false
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.login-container {
+  width: 100%;
+  height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #1a2a3a 0%, #304156 50%, #409eff 100%);
+
+  .login-card {
+    width: 420px;
+    background: #fff;
+    border-radius: 12px;
+    padding: 40px;
+    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
+
+    .login-header {
+      text-align: center;
+      margin-bottom: 32px;
+
+      .logo-icon {
+        font-size: 52px;
+        color: #409eff;
+      }
+
+      h2 {
+        margin: 12px 0 6px;
+        font-size: 22px;
+        color: #303133;
+        font-weight: 600;
+      }
+
+      p {
+        color: #909399;
+        font-size: 13px;
+      }
+    }
+
+    .login-form {
+      .captcha-wrap {
+        height: 40px;
+        border: 1px solid #dcdfe6;
+        border-radius: 4px;
+        overflow: hidden;
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #f5f7fa;
+        transition: border-color 0.2s;
+
+        &:hover { border-color: #409eff; }
+
+        .captcha-img {
+          width: 100%;
+          height: 100%;
+          object-fit: cover;
+        }
+
+        .captcha-placeholder {
+          font-size: 12px;
+          color: #909399;
+        }
+      }
+
+      .login-btn {
+        width: 100%;
+        height: 44px;
+        font-size: 16px;
+        letter-spacing: 4px;
+        margin-top: 8px;
+      }
+    }
+  }
+}
+</style>

+ 415 - 0
src/views/menu/index.vue

@@ -0,0 +1,415 @@
+<template>
+  <div class="menu-page">
+    <!-- 搜索工具栏 -->
+    <el-card shadow="never" class="search-card">
+      <div class="toolbar">
+        <el-input
+          v-model="searchForm.name"
+          placeholder="请输入菜单名称"
+          size="small"
+          style="width:180px"
+          clearable
+          @clear="handleSearch"
+        />
+        <el-select v-model="searchForm.status" placeholder="请选择状态" size="small" clearable style="width:120px">
+          <el-option label="启用" :value="1" />
+          <el-option label="停用" :value="0" />
+        </el-select>
+        <el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">查询</el-button>
+        <el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button>
+        <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd(0)">新增</el-button>
+      </div>
+    </el-card>
+
+    <!-- 树形表格 -->
+    <el-card shadow="never" class="table-card">
+      <el-table
+        :key="tableKey"
+        v-loading="loading"
+        :data="displayTree"
+        row-key="id"
+        :tree-props="{ children: 'children' }"
+        default-expand-all
+        border
+        size="small"
+        style="width:100%"
+      >
+        <el-table-column label="菜单名称" min-width="180">
+          <template slot-scope="{ row }">
+            <i v-if="row.icon" :class="row.icon" style="margin-right:6px;color:#409EFF"></i>
+            <span>{{ row.name }}</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="图标" width="80" align="center">
+          <template slot-scope="{ row }">
+            <i v-if="row.icon" :class="row.icon" style="font-size:16px;color:#606266"></i>
+            <span v-else style="color:#c0c4cc">—</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="排序" prop="sort" width="65" align="center" />
+
+        <el-table-column label="权限标识" min-width="150" show-overflow-tooltip>
+          <template slot-scope="{ row }">
+            <span style="color:#409EFF">{{ row.permission || '—' }}</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="路由地址" min-width="120" show-overflow-tooltip>
+          <template slot-scope="{ row }">
+            <span>{{ row.path || '—' }}</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="组件路径" min-width="160" show-overflow-tooltip>
+          <template slot-scope="{ row }">
+            <span>{{ row.component || '—' }}</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="类型" width="70" align="center">
+          <template slot-scope="{ row }">
+            <el-tag
+              :type="row.type === 'dir' ? '' : row.type === 'menu' ? 'success' : 'info'"
+              size="mini"
+            >{{ { dir: '目录', menu: '菜单', btn: '按钮' }[row.type] }}</el-tag>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="状态" width="100" align="center">
+          <template slot-scope="{ row }">
+            <span>
+              <el-tag :type="row.visible ? 'success' : 'warning'" size="mini" style="margin-right:4px">
+                {{ row.visible ? '显示' : '隐藏' }}
+              </el-tag>
+              <el-tag :type="row.status ? 'success' : 'danger'" size="mini">
+                {{ row.status ? '启用' : '停用' }}
+              </el-tag>
+            </span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="创建时间" prop="createdAt" min-width="155" />
+
+        <el-table-column label="操作" width="160" align="center" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button
+              v-if="row.type !== 'btn'"
+              type="text"
+              size="mini"
+              @click="handleAdd(row.id)"
+            >新增</el-button>
+            <el-divider v-if="row.type !== 'btn'" direction="vertical" />
+            <el-button type="text" size="mini" @click="handleEdit(row)">修改</el-button>
+            <el-divider direction="vertical" />
+            <el-button type="text" size="mini" style="color:#F56C6C" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 新增/修改 弹窗 -->
+    <el-dialog
+      :title="dialogTitle"
+      :visible.sync="dialogVisible"
+      width="560px"
+      :close-on-click-modal="false"
+      @close="resetDialog"
+    >
+      <el-form
+        ref="menuForm"
+        :model="form"
+        :rules="formRules"
+        label-width="90px"
+        size="small"
+      >
+        <el-form-item label="上级菜单" prop="parentId">
+          <el-select v-model="form.parentId" placeholder="请选择上级菜单" style="width:100%">
+            <el-option
+              v-for="opt in parentOptions"
+              :key="opt.id"
+              :label="opt.label"
+              :value="opt.id"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="菜单类型">
+          <el-radio-group v-model="form.type">
+            <el-radio label="dir">目录</el-radio>
+            <el-radio label="menu">菜单</el-radio>
+            <el-radio label="btn">按钮</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="菜单名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入菜单名称" />
+        </el-form-item>
+
+        <el-form-item v-if="form.type !== 'btn'" label="菜单图标">
+          <el-input v-model="form.icon" placeholder="如 el-icon-setting">
+            <i v-if="form.icon" slot="suffix" :class="form.icon" style="margin-right:8px;font-size:16px;line-height:30px"></i>
+          </el-input>
+        </el-form-item>
+
+        <el-form-item label="排序" prop="sort">
+          <el-input-number
+            v-model="form.sort"
+            :min="1"
+            :max="999"
+            controls-position="right"
+            style="width:100%"
+          />
+        </el-form-item>
+
+        <el-form-item label="权限标识">
+          <el-input v-model="form.permission" placeholder="如 role:add" />
+        </el-form-item>
+
+        <template v-if="form.type === 'menu'">
+          <el-form-item label="路由地址" prop="path">
+            <el-input v-model="form.path" placeholder="如 /role" />
+          </el-form-item>
+          <el-form-item label="组件路径" prop="component">
+            <el-input v-model="form.component" placeholder="如 views/role/index" />
+          </el-form-item>
+        </template>
+
+        <el-form-item v-if="form.type !== 'btn'" label="是否显示">
+          <el-radio-group v-model="form.visible">
+            <el-radio :label="1">显示</el-radio>
+            <el-radio :label="0">隐藏</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="状态">
+          <el-radio-group v-model="form.status">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">停用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button size="small" @click="dialogVisible = false">取 消</el-button>
+        <el-button type="primary" size="small" :loading="saving" @click="handleSave">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getMenuList, saveMenu, deleteMenu } from '@/api/menu'
+
+const DEFAULT_FORM = () => ({
+  id: null,
+  parentId: 0,
+  name: '',
+  icon: '',
+  sort: 1,
+  permission: '',
+  path: '',
+  component: '',
+  type: 'menu',
+  visible: 1,
+  status: 1
+})
+
+export default {
+  name: 'MenuManagement',
+  data() {
+    return {
+      loading: false,
+      menuTree: [],
+      searchForm: { name: '', status: '' },
+      tableKey: 0,
+
+      dialogVisible: false,
+      saving: false,
+      isEdit: false,
+      form: DEFAULT_FORM()
+    }
+  },
+  computed: {
+    dialogTitle() {
+      return this.isEdit ? '修改菜单' : '新增菜单'
+    },
+
+    // 根据 type 显示可选的上级菜单
+    parentOptions() {
+      const opts = []
+      if (this.form.type === 'dir') {
+        opts.push({ id: 0, label: '顶级菜单' })
+      }
+      const traverse = (nodes, indent) => {
+        nodes.forEach(node => {
+          const prefix = ' '.repeat(indent)
+          if (node.type === 'dir') {
+            if (this.form.type === 'menu') opts.push({ id: node.id, label: prefix + node.name })
+            // dir can also be parent of dir
+            if (this.form.type === 'dir' && indent > 0) opts.push({ id: node.id, label: prefix + node.name })
+          }
+          if (node.type === 'menu' && this.form.type === 'btn') {
+            opts.push({ id: node.id, label: prefix + node.name })
+          }
+          if (node.children) traverse(node.children, indent + 1)
+        })
+      }
+      traverse(this.menuTree, 1)
+      return opts
+    },
+
+    formRules() {
+      const rules = {
+        name:     [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
+        sort:     [{ required: true, message: '请输入排序',     trigger: 'blur' }],
+        parentId: [{ required: true, message: '请选择上级菜单', trigger: 'change' }]
+      }
+      if (this.form.type === 'menu') {
+        rules.path      = [{ required: true, message: '请输入路由地址', trigger: 'blur' }]
+        rules.component = [{ required: true, message: '请输入组件路径', trigger: 'blur' }]
+      }
+      return rules
+    },
+
+    displayTree() {
+      const { name, status } = this.searchForm
+      if (!name && status === '') return this.menuTree
+      return this.filterNodes(this.menuTree, name, status)
+    }
+  },
+  created() {
+    this.fetchTree()
+  },
+  methods: {
+    async fetchTree() {
+      this.loading = true
+      try {
+        const res = await getMenuList()
+        this.menuTree = res.data
+      } finally {
+        this.loading = false
+      }
+    },
+
+    filterNodes(nodes, keyword, status) {
+      return nodes.reduce((result, node) => {
+        const children = node.children ? this.filterNodes(node.children, keyword, status) : undefined
+        const selfMatch =
+          (!keyword || node.name.includes(keyword)) &&
+          (status === '' || node.status === status)
+        if (selfMatch) {
+          result.push({ ...node })  // show with all original children
+        } else if (children && children.length > 0) {
+          result.push({ ...node, children })
+        }
+        return result
+      }, [])
+    },
+
+    handleSearch() {
+      this.tableKey++
+    },
+
+    handleReset() {
+      this.searchForm = { name: '', status: '' }
+      this.tableKey++
+    },
+
+    handleAdd(parentId) {
+      this.isEdit = false
+      this.form = { ...DEFAULT_FORM(), parentId }
+      // Pre-select type based on parent
+      if (parentId !== 0) {
+        const parent = this.findNodeById(this.menuTree, parentId)
+        if (parent) {
+          this.form.type = parent.type === 'dir' ? 'menu' : 'btn'
+        }
+      } else {
+        this.form.type = 'dir'
+      }
+      this.dialogVisible = true
+    },
+
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = { ...DEFAULT_FORM(), ...row }
+      this.dialogVisible = true
+    },
+
+    handleDelete(row) {
+      const hasChildren = row.children && row.children.length > 0
+      const msg = hasChildren
+        ? `「${row.name}」包含子菜单,删除后子菜单也将一并删除,是否确认?`
+        : `确认删除菜单「${row.name}」?`
+      this.$confirm(msg, '删除确认', {
+        confirmButtonText: '确认删除',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        await deleteMenu(row.id)
+        this.$message.success(`「${row.name}」已删除`)
+        await this.fetchTree()
+      }).catch(() => {})
+    },
+
+    handleSave() {
+      this.$refs.menuForm.validate(async valid => {
+        if (!valid) return
+        this.saving = true
+        try {
+          await saveMenu(this.form)
+          this.$message.success(this.isEdit ? '修改成功' : '新增成功')
+          this.dialogVisible = false
+          this.tableKey++
+          await this.fetchTree()
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+
+    resetDialog() {
+      this.$refs.menuForm && this.$refs.menuForm.resetFields()
+      this.form = DEFAULT_FORM()
+    },
+
+    findNodeById(nodes, id) {
+      for (const node of nodes) {
+        if (node.id === id) return node
+        if (node.children) {
+          const found = this.findNodeById(node.children, id)
+          if (found) return found
+        }
+      }
+      return null
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.menu-page {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  .search-card {
+    ::v-deep .el-card__body { padding: 12px 16px; }
+
+    .toolbar {
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 8px;
+    }
+  }
+
+  .table-card {
+    ::v-deep .el-table__row td {
+      vertical-align: middle;
+    }
+  }
+}
+</style>

+ 310 - 0
src/views/product/index.vue

@@ -0,0 +1,310 @@
+<template>
+  <div class="product-page">
+    <!-- 顶部工具栏 -->
+    <el-card shadow="never" class="toolbar-card">
+      <el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增商品</el-button>
+    </el-card>
+
+    <!-- 数据表格 -->
+    <el-card shadow="never" class="table-card">
+      <el-table
+        v-loading="loading"
+        :data="productList"
+        border
+        stripe
+        style="width: 100%"
+      >
+        <el-table-column label="商品图片" width="100" align="center">
+          <template slot-scope="{ row }">
+            <div class="product-img" :style="{ background: getImgColor(row.id) }">
+              <i class="el-icon-goods"></i>
+            </div>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="商品名称" prop="name" min-width="200" />
+
+        <el-table-column label="价格(元)" width="130" align="right">
+          <template slot-scope="{ row }">
+            <span class="price">¥{{ row.price.toLocaleString() }}</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="库存" prop="stock" width="100" align="center">
+          <template slot-scope="{ row }">
+            <span :class="{ 'stock-empty': row.stock === 0 }">{{ row.stock }}</span>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="上架状态" width="110" align="center">
+          <template slot-scope="{ row }">
+            <el-tag
+              :type="row.status === 1 ? 'success' : 'info'"
+              size="small"
+              effect="light"
+            >
+              {{ row.status === 1 ? '已上架' : '已下架' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="操作" width="160" align="center" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button
+              size="mini"
+              type="primary"
+              plain
+              icon="el-icon-edit"
+              @click="handleEdit(row)"
+            >编辑</el-button>
+            <el-button
+              size="mini"
+              type="danger"
+              plain
+              icon="el-icon-delete"
+              @click="handleDelete(row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 新增 / 编辑对话框(复用同一个) -->
+    <el-dialog
+      :title="dialogTitle"
+      :visible.sync="dialogVisible"
+      width="480px"
+      :close-on-click-modal="false"
+      @close="resetForm"
+    >
+      <el-form
+        ref="productForm"
+        :model="productForm"
+        :rules="formRules"
+        label-width="90px"
+      >
+        <el-form-item label="商品名称" prop="name">
+          <el-input
+            v-model="productForm.name"
+            placeholder="请输入商品名称"
+            clearable
+          />
+        </el-form-item>
+
+        <el-form-item label="商品价格" prop="price">
+          <el-input-number
+            v-model="productForm.price"
+            :min="0"
+            :precision="2"
+            :step="100"
+            style="width: 100%"
+            placeholder="请输入价格"
+          />
+        </el-form-item>
+
+        <el-form-item label="库存数量" prop="stock">
+          <el-input-number
+            v-model="productForm.stock"
+            :min="0"
+            :step="10"
+            style="width: 100%"
+            placeholder="请输入库存"
+          />
+        </el-form-item>
+
+        <el-form-item label="上架状态" prop="status">
+          <el-radio-group v-model="productForm.status">
+            <el-radio :label="1">上架</el-radio>
+            <el-radio :label="0">下架</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="handleSubmit">确 认</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getProductList } from '@/api/product'
+
+// 商品图片占位色,按 id 取模轮换
+const IMG_COLORS = [
+  '#dbeafe', '#dcfce7', '#fef9c3', '#fee2e2',
+  '#ede9fe', '#e0f2fe', '#fce7f3', '#f3f4f6'
+]
+const ICON_COLORS = [
+  '#3b82f6', '#22c55e', '#eab308', '#ef4444',
+  '#8b5cf6', '#0ea5e9', '#ec4899', '#6b7280'
+]
+
+// 表单初始值
+const DEFAULT_FORM = () => ({
+  name: '',
+  price: 0,
+  stock: 0,
+  status: 1
+})
+
+export default {
+  name: 'ProductManagement',
+  data() {
+    return {
+      loading: false,
+      productList: [],
+
+      // 对话框
+      dialogVisible: false,
+      dialogMode: 'add',    // 'add' | 'edit'
+      editingId: null,
+      productForm: DEFAULT_FORM(),
+
+      formRules: {
+        name: [
+          { required: true, message: '请输入商品名称', trigger: 'blur' },
+          { min: 2, message: '名称至少2个字符', trigger: 'blur' }
+        ],
+        price: [
+          { required: true, message: '请输入价格', trigger: 'blur' }
+        ],
+        stock: [
+          { required: true, message: '请输入库存', trigger: 'blur' }
+        ]
+      }
+    }
+  },
+  computed: {
+    dialogTitle() {
+      return this.dialogMode === 'add' ? '新增商品' : '编辑商品'
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      this.loading = true
+      try {
+        const res = await getProductList()
+        this.productList = res.data.list
+      } finally {
+        this.loading = false
+      }
+    },
+
+    // 打开新增对话框
+    handleAdd() {
+      this.dialogMode = 'add'
+      this.editingId = null
+      this.productForm = DEFAULT_FORM()
+      this.dialogVisible = true
+    },
+
+    // 打开编辑对话框,回填当前行数据
+    handleEdit(row) {
+      this.dialogMode = 'edit'
+      this.editingId = row.id
+      this.productForm = {
+        name: row.name,
+        price: row.price,
+        stock: row.stock,
+        status: row.status
+      }
+      this.dialogVisible = true
+    },
+
+    // 提交表单(仅展示 UI,不做真实提交)
+    handleSubmit() {
+      this.$refs.productForm.validate(valid => {
+        if (!valid) return
+
+        if (this.dialogMode === 'add') {
+          // 前端模拟新增:生成临时 id 并追加到列表
+          const newId = Math.max(...this.productList.map(p => p.id), 0) + 1
+          this.productList.push({ id: newId, ...this.productForm })
+          this.$message.success('新增商品成功')
+        } else {
+          // 前端模拟编辑:更新对应项
+          const idx = this.productList.findIndex(p => p.id === this.editingId)
+          if (idx !== -1) {
+            this.$set(this.productList, idx, { id: this.editingId, ...this.productForm })
+          }
+          this.$message.success('编辑商品成功')
+        }
+        this.dialogVisible = false
+      })
+    },
+
+    // 删除:弹出确认框,确认后前端模拟移除
+    handleDelete(row) {
+      this.$confirm(
+        `确认删除商品「${row.name}」?此操作不可撤销。`,
+        '删除确认',
+        {
+          confirmButtonText: '确认删除',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }
+      ).then(() => {
+        this.productList = this.productList.filter(p => p.id !== row.id)
+        this.$message.success(`商品「${row.name}」已删除`)
+      }).catch(() => {})
+    },
+
+    // 关闭对话框时重置表单校验状态
+    resetForm() {
+      this.$refs.productForm && this.$refs.productForm.resetFields()
+    },
+
+    getImgColor(id) {
+      return IMG_COLORS[id % IMG_COLORS.length]
+    },
+
+    getIconColor(id) {
+      return ICON_COLORS[id % ICON_COLORS.length]
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.product-page {
+  .toolbar-card {
+    margin-bottom: 16px;
+
+    ::v-deep .el-card__body {
+      padding: 14px 20px;
+    }
+  }
+
+  .table-card {
+    .product-img {
+      width: 52px;
+      height: 52px;
+      border-radius: 6px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      margin: 0 auto;
+
+      i {
+        font-size: 24px;
+        color: #409eff;
+      }
+    }
+
+    .price {
+      color: #f56c6c;
+      font-weight: 600;
+    }
+
+    .stock-empty {
+      color: #f56c6c;
+      font-weight: 600;
+    }
+  }
+}
+</style>

+ 323 - 0
src/views/role/index.vue

@@ -0,0 +1,323 @@
+<template>
+  <div class="role-page">
+    <!-- 搜索工具栏 -->
+    <el-card shadow="never" class="search-card">
+      <div class="toolbar">
+        <el-input
+          v-model="searchForm.name"
+          placeholder="角色名称"
+          size="small"
+          style="width:160px"
+          clearable
+          @clear="handleSearch"
+        />
+        <el-select v-model="searchForm.status" placeholder="状态" size="small" clearable style="width:90px">
+          <el-option label="启用" :value="1" />
+          <el-option label="停用" :value="0" />
+        </el-select>
+        <el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">查询</el-button>
+        <el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button>
+        <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd">新建</el-button>
+      </div>
+    </el-card>
+
+    <!-- 数据表格 -->
+    <el-card shadow="never" class="table-card">
+      <el-table
+        v-loading="loading"
+        :data="pageData"
+        border
+        size="small"
+        style="width:100%"
+      >
+        <el-table-column label="角色名称" prop="name" min-width="130" />
+        <el-table-column label="权限类型" min-width="110">
+          <template slot-scope="{ row }">
+            <span>{{ row.permType === 'all' ? '全部权限' : '自定义权限' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="数据范围" prop="dataScope" min-width="160" show-overflow-tooltip />
+        <el-table-column label="排序" prop="sort" width="70" align="center" />
+        <el-table-column label="状态" width="75" align="center">
+          <template slot-scope="{ row }">
+            <el-switch
+              v-model="row.status"
+              :active-value="1"
+              :inactive-value="0"
+              active-color="#409EFF"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="描述" prop="remark" min-width="160" show-overflow-tooltip />
+        <el-table-column label="创建时间" prop="createdAt" min-width="155" />
+        <el-table-column label="操作" width="150" align="center" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button type="text" size="mini" @click="handleEdit(row)">编辑</el-button>
+            <el-divider direction="vertical" />
+            <el-button type="text" size="mini" @click="handlePermission(row)">菜单权限</el-button>
+            <el-divider direction="vertical" />
+            <el-button type="text" size="mini" style="color:#F56C6C" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-wrapper">
+        <span class="pagination-info">共{{ total }}条记录 第{{ currentPage }}/{{ totalPages }}页</span>
+        <el-pagination
+          background
+          :current-page="currentPage"
+          :page-sizes="[10, 20]"
+          :page-size="pageSize"
+          :total="total"
+          layout="prev, pager, next, sizes, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑 弹窗 -->
+    <el-dialog
+      :title="dialogTitle"
+      :visible.sync="dialogVisible"
+      width="520px"
+      :close-on-click-modal="false"
+      @close="resetDialog"
+    >
+      <el-form
+        ref="roleForm"
+        :model="form"
+        :rules="formRules"
+        label-width="90px"
+        size="small"
+      >
+        <el-form-item label="角色名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入角色名称" />
+        </el-form-item>
+
+        <el-form-item label="权限字符" prop="roleKey">
+          <el-input v-model="form.roleKey" placeholder="如 lab_admin(唯一标识)" />
+        </el-form-item>
+
+        <el-form-item label="权限类型" prop="permType">
+          <el-radio-group v-model="form.permType">
+            <el-radio label="all">全部权限</el-radio>
+            <el-radio label="custom">自定义权限</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="数据范围" prop="dataScope">
+          <el-select v-model="form.dataScope" placeholder="请选择数据范围" style="width:100%">
+            <el-option v-for="s in dataScopes" :key="s" :label="s" :value="s" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="排序" prop="sort">
+          <el-input-number
+            v-model="form.sort"
+            :min="1"
+            :max="999"
+            controls-position="right"
+            style="width:100%"
+          />
+        </el-form-item>
+
+        <el-form-item label="状态">
+          <el-radio-group v-model="form.status">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">停用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="描述">
+          <el-input
+            v-model="form.remark"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入角色描述"
+          />
+        </el-form-item>
+      </el-form>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button size="small" @click="dialogVisible = false">取 消</el-button>
+        <el-button type="primary" size="small" :loading="saving" @click="handleSave">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getRoleList, saveRole, deleteRole, DATA_SCOPES } from '@/api/role'
+
+const DEFAULT_FORM = () => ({
+  id: null,
+  name: '',
+  roleKey: '',
+  permType: 'custom',
+  dataScope: '',
+  sort: 1,
+  status: 1,
+  remark: ''
+})
+
+export default {
+  name: 'RoleManagement',
+  data() {
+    return {
+      loading: false,
+      allList: [],
+      filteredList: [],
+      total: 0,
+      currentPage: 1,
+      pageSize: 10,
+
+      searchForm: { name: '', status: '' },
+      dataScopes: DATA_SCOPES,
+
+      dialogVisible: false,
+      saving: false,
+      isEdit: false,
+      form: DEFAULT_FORM(),
+      formRules: {
+        name:      [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
+        roleKey:   [{ required: true, message: '请输入权限字符', trigger: 'blur' }],
+        permType:  [{ required: true, message: '请选择权限类型', trigger: 'change' }],
+        dataScope: [{ required: true, message: '请选择数据范围', trigger: 'change' }],
+        sort:      [{ required: true, message: '请输入排序',     trigger: 'blur' }]
+      }
+    }
+  },
+  computed: {
+    pageData() {
+      const start = (this.currentPage - 1) * this.pageSize
+      return this.filteredList.slice(start, start + this.pageSize)
+    },
+    totalPages() {
+      return Math.ceil(this.total / this.pageSize) || 1
+    },
+    dialogTitle() {
+      return this.isEdit ? '编辑角色' : '新增角色'
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      this.loading = true
+      try {
+        const res = await getRoleList()
+        this.allList = res.data.list
+        this.applyFilters()
+      } finally {
+        this.loading = false
+      }
+    },
+
+    applyFilters() {
+      let list = [...this.allList]
+      const { name, status } = this.searchForm
+      if (name)       list = list.filter(r => r.name.includes(name))
+      if (status !== '') list = list.filter(r => r.status === status)
+      this.filteredList = list
+      this.total = list.length
+      this.currentPage = 1
+    },
+
+    handleSearch() { this.applyFilters() },
+    handleReset() {
+      this.searchForm = { name: '', status: '' }
+      this.applyFilters()
+    },
+
+    handleAdd() {
+      this.isEdit = false
+      this.form = DEFAULT_FORM()
+      this.dialogVisible = true
+    },
+
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = { ...DEFAULT_FORM(), ...row }
+      this.dialogVisible = true
+    },
+
+    handlePermission(row) {
+      this.$router.push(`/role/permission?id=${row.id}&name=${encodeURIComponent(row.name)}`)
+    },
+
+    handleDelete(row) {
+      this.$confirm(`确认删除角色「${row.name}」?`, '删除确认', {
+        confirmButtonText: '确认删除',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        await deleteRole(row.id)
+        this.allList = this.allList.filter(r => r.id !== row.id)
+        this.applyFilters()
+        this.$message.success(`角色「${row.name}」已删除`)
+      }).catch(() => {})
+    },
+
+    handleSave() {
+      this.$refs.roleForm.validate(async valid => {
+        if (!valid) return
+        this.saving = true
+        try {
+          await saveRole(this.form)
+          this.$message.success(this.isEdit ? '保存成功' : '新增成功')
+          this.dialogVisible = false
+          await this.fetchList()
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+
+    resetDialog() {
+      this.$refs.roleForm && this.$refs.roleForm.resetFields()
+      this.form = DEFAULT_FORM()
+    },
+
+    handleSizeChange(size) { this.pageSize = size; this.currentPage = 1 },
+    handleCurrentChange(page) { this.currentPage = page }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.role-page {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  .search-card {
+    ::v-deep .el-card__body { padding: 12px 16px; }
+
+    .toolbar {
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 8px;
+    }
+  }
+
+  .table-card {
+    .pagination-wrapper {
+      display: flex;
+      align-items: center;
+      justify-content: flex-end;
+      margin-top: 16px;
+      gap: 16px;
+
+      .pagination-info {
+        font-size: 13px;
+        color: #606266;
+        white-space: nowrap;
+      }
+    }
+  }
+}
+</style>

+ 282 - 0
src/views/role/permission.vue

@@ -0,0 +1,282 @@
+<template>
+  <div class="permission-page">
+    <!-- 页面标题 + 返回 -->
+    <div class="page-header">
+      <el-button icon="el-icon-arrow-left" size="small" @click="$router.push('/role')">返回列表</el-button>
+      <span class="page-title">权限配置 — {{ roleName }}</span>
+    </div>
+
+    <el-card shadow="never" v-loading="loading">
+      <!-- 权限矩阵表格 -->
+      <table class="perm-table" border="0" cellspacing="0" cellpadding="0">
+        <thead>
+          <tr>
+            <th class="col-group">模块菜单</th>
+            <th class="col-page">页面</th>
+            <th class="col-btns">权限</th>
+          </tr>
+        </thead>
+        <tbody>
+          <template v-for="group in menuGroups">
+            <template v-if="menuChildren(group).length > 0">
+              <tr
+                v-for="(menu, mIdx) in menuChildren(group)"
+                :key="group.id + '_' + menu.id"
+              >
+                <!-- 模块单元格:第一行渲染,rowspan = 菜单页面数 -->
+                <td
+                  v-if="mIdx === 0"
+                  :rowspan="menuChildren(group).length"
+                  class="cell-group"
+                >
+                  <el-checkbox
+                    :value="groupChecked(group)"
+                    :indeterminate="groupIndeterminate(group)"
+                    @change="val => toggleGroup(group, val)"
+                  >{{ group.name }}</el-checkbox>
+                </td>
+
+                <!-- 页面单元格 -->
+                <td class="cell-page">
+                  <el-checkbox
+                    :value="pageChecked(menu)"
+                    :indeterminate="pageIndeterminate(menu)"
+                    @change="val => togglePage(group, menu, val)"
+                  >{{ menu.name }}</el-checkbox>
+                </td>
+
+                <!-- 按钮权限单元格 -->
+                <td class="cell-btns">
+                  <template v-if="btnChildren(menu).length > 0">
+                    <el-checkbox
+                      v-for="btn in btnChildren(menu)"
+                      :key="btn.id"
+                      :value="selectedSet.has(btn.id)"
+                      @change="val => toggleBtn(group, menu, btn, val)"
+                    >{{ btn.name }}</el-checkbox>
+                  </template>
+                  <span v-else class="no-btn">暂无按钮权限</span>
+                </td>
+              </tr>
+            </template>
+          </template>
+        </tbody>
+      </table>
+
+      <!-- 底部按钮 -->
+      <div class="form-footer">
+        <el-button size="small" @click="$router.push('/role')">取 消</el-button>
+        <el-button type="primary" size="small" :loading="saving" @click="handleSave">保 存</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getMenuTree } from '@/api/menu'
+import { getRolePermission, saveRolePermission } from '@/api/role'
+
+export default {
+  name: 'RolePermission',
+  data() {
+    return {
+      loading: false,
+      saving: false,
+      menuTree: [],
+      selectedIds: []   // 已选的菜单/按钮 ID 数组(string[])
+    }
+  },
+  computed: {
+    roleId()   { return this.$route.query.id },
+    roleName() { return decodeURIComponent(this.$route.query.name || '') },
+
+    // 顶层目录节点
+    menuGroups() {
+      return this.menuTree.filter(n => n.type === 'dir')
+    },
+
+    // Set 结构用于 O(1) 查找
+    selectedSet() {
+      return new Set(this.selectedIds)
+    },
+
+    // ===== 勾选状态计算 =====
+    groupChecked() {
+      return group => {
+        const all = this.allGroupIds(group)
+        return all.length > 0 && all.every(id => this.selectedSet.has(id))
+      }
+    },
+    groupIndeterminate() {
+      return group => {
+        const all = this.allGroupIds(group)
+        const cnt = all.filter(id => this.selectedSet.has(id)).length
+        return cnt > 0 && cnt < all.length
+      }
+    },
+    pageChecked() {
+      return menu => {
+        const all = this.allPageIds(menu)
+        return all.length > 0 && all.every(id => this.selectedSet.has(id))
+      }
+    },
+    pageIndeterminate() {
+      return menu => {
+        const all = this.allPageIds(menu)
+        const cnt = all.filter(id => this.selectedSet.has(id)).length
+        return cnt > 0 && cnt < all.length
+      }
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    menuChildren(group) {
+      return (group.children || []).filter(c => c.type === 'menu')
+    },
+    btnChildren(menu) {
+      return (menu.children || []).filter(c => c.type === 'btn')
+    },
+
+    /** 某个页面(menu)涉及的所有 ID:[menu.id, ...btn.ids] */
+    allPageIds(menu) {
+      return [menu.id, ...this.btnChildren(menu).map(b => b.id)]
+    },
+
+    /** 某个模块(group)涉及的所有 ID */
+    allGroupIds(group) {
+      const ids = [group.id]
+      this.menuChildren(group).forEach(menu => ids.push(...this.allPageIds(menu)))
+      return ids
+    },
+
+    async loadData() {
+      this.loading = true
+      try {
+        const [menuRes, permRes] = await Promise.all([
+          getMenuTree(),
+          getRolePermission(this.roleId)
+        ])
+        this.menuTree    = menuRes.data || []
+        // API 返回 string[] 的已选 menuId 列表
+        this.selectedIds = (permRes.data || []).map(String)
+      } finally {
+        this.loading = false
+      }
+    },
+
+    // ===== 联动切换 =====
+
+    /** 切换单个按钮:选中时自动勾选父级 menu & group */
+    toggleBtn(group, menu, btn, val) {
+      const set = new Set(this.selectedIds)
+      if (val) {
+        set.add(btn.id)
+        set.add(menu.id)   // 自动勾选父页面
+        set.add(group.id)  // 自动勾选父模块
+      } else {
+        set.delete(btn.id)
+      }
+      this.selectedIds = Array.from(set)
+    },
+
+    /** 切换整个页面(menu + 所有 btn) */
+    togglePage(group, menu, val) {
+      const set = new Set(this.selectedIds)
+      if (val) {
+        this.allPageIds(menu).forEach(id => set.add(id))
+        set.add(group.id)  // 自动勾选父模块
+      } else {
+        this.allPageIds(menu).forEach(id => set.delete(id))
+      }
+      this.selectedIds = Array.from(set)
+    },
+
+    /** 切换整个模块(group + 所有 menu + btn) */
+    toggleGroup(group, val) {
+      const set = new Set(this.selectedIds)
+      if (val) {
+        this.allGroupIds(group).forEach(id => set.add(id))
+      } else {
+        this.allGroupIds(group).forEach(id => set.delete(id))
+      }
+      this.selectedIds = Array.from(set)
+    },
+
+    async handleSave() {
+      this.saving = true
+      try {
+        await saveRolePermission(this.roleId, this.selectedIds)
+        this.$message.success('权限保存成功')
+        this.$router.push('/role')
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.permission-page {
+  .page-header {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    margin-bottom: 16px;
+
+    .page-title {
+      font-size: 16px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  .perm-table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 13px;
+    color: #303133;
+
+    th, td {
+      border: 1px solid #ebeef5;
+      padding: 10px 14px;
+      vertical-align: middle;
+    }
+
+    thead th {
+      background: #f5f7fa;
+      font-weight: 600;
+      color: #606266;
+    }
+
+    .col-group { width: 160px; }
+    .col-page  { width: 150px; }
+    .col-btns  { }
+
+    .cell-group {
+      background: #fafafa;
+      font-weight: 500;
+      text-align: center;
+    }
+
+    .cell-btns {
+      ::v-deep .el-checkbox { margin-right: 20px; margin-bottom: 4px; }
+      .no-btn { font-size: 12px; color: #c0c4cc; }
+    }
+
+    tbody tr:nth-child(even) .cell-page,
+    tbody tr:nth-child(even) .cell-btns { background: #fafafa; }
+  }
+
+  .form-footer {
+    display: flex;
+    justify-content: center;
+    gap: 16px;
+    padding-top: 20px;
+    border-top: 1px solid #f0f0f0;
+    margin-top: 16px;
+  }
+}
+</style>

+ 269 - 0
src/views/usage/index.vue

@@ -0,0 +1,269 @@
+<template>
+  <div class="usage-list">
+    <!-- 搜索区域 -->
+    <el-card shadow="never" class="search-card">
+      <el-form :model="searchForm" label-width="80px" size="small">
+        <el-row :gutter="16">
+          <el-col :span="6">
+            <el-form-item label="分类">
+              <el-select v-model="searchForm.category" placeholder="请选择设备分类" clearable style="width:100%">
+                <el-option v-for="c in categories" :key="c" :label="c" :value="c" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="设备名称">
+              <el-input v-model="searchForm.deviceName" placeholder="请输入设备名称" clearable />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="实验室名称">
+              <el-input v-model="searchForm.labName" placeholder="请输入实验室名称" clearable />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="实验室编号">
+              <el-input v-model="searchForm.labNo" placeholder="请输入实验室编号" clearable />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="16">
+          <el-col :span="10">
+            <el-form-item label="使用日期">
+              <el-date-picker
+                v-model="searchForm.dateRange"
+                type="daterange"
+                range-separator="→"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="yyyy-MM-dd"
+                style="width:100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="使用类型">
+              <el-select v-model="searchForm.usageType" placeholder="请选择使用类型" clearable style="width:100%">
+                <el-option v-for="t in usageTypes" :key="t" :label="t" :value="t" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label-width="0">
+              <el-button type="primary" icon="el-icon-search"  size="small" @click="handleSearch">查询</el-button>
+              <el-button               icon="el-icon-refresh"  size="small" @click="handleReset">重置</el-button>
+              <el-button               icon="el-icon-upload2"  size="small" @click="tip('导出excel')">导出excel</el-button>
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+    </el-card>
+
+    <!-- 提示说明 -->
+    <el-alert
+      type="warning"
+      :closable="false"
+      show-icon
+      class="notice-alert"
+    >
+      <template slot="title">
+        <span>说明:</span>
+        <span>① 显示权限可查的设备使用记录</span>
+        <el-divider direction="vertical" />
+        <span>② 使用时长 = 结束时间 - 开机时间</span>
+      </template>
+    </el-alert>
+
+    <!-- 数据表格 -->
+    <el-card shadow="never" class="table-card">
+      <el-table
+        v-loading="loading"
+        :data="usageList"
+        border
+        size="small"
+        style="width:100%"
+      >
+        <el-table-column type="selection" width="45" align="center" />
+        <el-table-column label="编号"       prop="deviceNo"   width="115" />
+        <el-table-column label="分类"       prop="category"   min-width="120" show-overflow-tooltip />
+        <el-table-column label="设备名称"   prop="deviceName" min-width="110" show-overflow-tooltip />
+        <el-table-column label="使用类型"   prop="usageType"  width="80"  align="center">
+          <template slot-scope="{ row }">
+            <el-tag :type="usageTypeTag(row.usageType)" size="mini" effect="plain">
+              {{ row.usageType }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="开机时间"   prop="startTime"          width="140" />
+        <el-table-column label="结束时间"   prop="endTime"            width="140" />
+        <el-table-column label="使用时长"   width="110" align="center">
+          <template slot-scope="{ row }">
+            <!-- 使用时长用蓝色高亮显示 -->
+            <span class="duration-text">{{ calcDuration(row.startTime, row.endTime) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="预计关机时间" prop="expectedShutdown" width="140" />
+        <el-table-column label="使用人"     prop="user"               min-width="150" show-overflow-tooltip />
+        <el-table-column label="使用人部门" prop="userDept"           min-width="100" show-overflow-tooltip />
+        <el-table-column label="登记时间"   prop="recordTime"         width="140" />
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-wrapper">
+        <span class="pagination-info">共{{ total }}条记录 第{{ currentPage }}/{{ totalPages }}页</span>
+        <el-pagination
+          background
+          :current-page="currentPage"
+          :page-sizes="[5, 10, 20]"
+          :page-size="pageSize"
+          :total="total"
+          layout="prev, pager, next, sizes, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getUsageList, USAGE_TYPES } from '@/api/usage'
+import { DEVICE_CATEGORIES } from '@/api/device'
+
+export default {
+  name: 'UsageList',
+  data() {
+    return {
+      loading: false,
+      usageList: [],
+      total: 0,
+      currentPage: 1,
+      pageSize: 5,
+
+      searchForm: {
+        category: '',
+        deviceName: '',
+        labName: '',
+        labNo: '',
+        dateRange: [],
+        usageType: ''
+      },
+
+      categories: DEVICE_CATEGORIES,
+      usageTypes: USAGE_TYPES
+    }
+  },
+  computed: {
+    totalPages() {
+      return Math.ceil(this.total / this.pageSize) || 1
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      this.loading = true
+      try {
+        const res = await getUsageList({
+          ...this.searchForm,
+          page: this.currentPage,
+          pageSize: this.pageSize
+        })
+        this.usageList = res.data.list
+        this.total     = res.data.total
+      } finally {
+        this.loading = false
+      }
+    },
+
+    handleSearch() {
+      this.currentPage = 1
+      this.fetchList()
+    },
+
+    handleReset() {
+      this.searchForm = { category: '', deviceName: '', labName: '', labNo: '', dateRange: [], usageType: '' }
+      this.currentPage = 1
+      this.fetchList()
+    },
+
+    handleSizeChange(size) {
+      this.pageSize = size
+      this.currentPage = 1
+      this.fetchList()
+    },
+
+    handleCurrentChange(page) {
+      this.currentPage = page
+      this.fetchList()
+    },
+
+    tip(name) {
+      this.$message.info(`${name}功能待对接后端实现`)
+    },
+
+    // 计算使用时长(结束时间 - 开机时间),返回中文格式字符串
+    calcDuration(startTime, endTime) {
+      if (!startTime || !endTime) return '-'
+      const s = new Date(startTime.replace(' ', 'T'))
+      const e = new Date(endTime.replace(' ', 'T'))
+      const mins = Math.round((e - s) / 60000)
+      if (mins <= 0) return '-'
+      if (mins >= 60) {
+        const h = Math.floor(mins / 60)
+        const m = mins % 60
+        return m > 0 ? `${h}小时${m}分钟` : `${h}小时`
+      }
+      return `${mins}分钟`
+    },
+
+    usageTypeTag(type) {
+      const map = { '科研': 'primary', '教学': 'success', '自用': '', '借用': 'warning', '共享': 'info' }
+      return map[type] || ''
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.usage-list {
+  .search-card {
+    margin-bottom: 12px;
+    ::v-deep .el-card__body { padding: 16px 16px 4px; }
+  }
+
+  .notice-alert {
+    margin-bottom: 12px;
+
+    ::v-deep .el-alert__title {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      font-size: 13px;
+    }
+  }
+
+  .table-card {
+    // 使用时长蓝色高亮
+    .duration-text {
+      color: #409eff;
+      font-weight: 600;
+    }
+
+    .pagination-wrapper {
+      display: flex;
+      align-items: center;
+      justify-content: flex-end;
+      margin-top: 16px;
+      gap: 16px;
+
+      .pagination-info {
+        font-size: 13px;
+        color: #606266;
+        white-space: nowrap;
+      }
+    }
+  }
+}
+</style>

+ 292 - 0
src/views/user/form.vue

@@ -0,0 +1,292 @@
+<template>
+  <div class="user-form">
+    <!-- 页面标题 + 返回 -->
+    <div class="page-header">
+      <el-button icon="el-icon-arrow-left" size="small" @click="$router.push('/user')">返回列表</el-button>
+      <span class="page-title">{{ pageTitle }}</span>
+    </div>
+
+    <el-card shadow="never">
+      <div class="form-body">
+        <!-- 左侧:头像上传 -->
+        <div class="avatar-section">
+          <el-upload
+            action="#"
+            :auto-upload="false"
+            :show-file-list="false"
+            :disabled="isView"
+            accept="image/*"
+            :on-change="handleAvatarChange"
+          >
+            <div class="avatar-box">
+              <img v-if="form.avatar" :src="form.avatar" class="avatar-img" />
+              <!-- 仿照截图的图片占位 SVG -->
+              <svg v-else class="avatar-placeholder" viewBox="0 0 200 160" xmlns="http://www.w3.org/2000/svg">
+                <rect width="200" height="160" fill="#d0e8f4" />
+                <circle cx="152" cy="38" r="20" fill="#b4d0e2" />
+                <polygon points="0,160 75,60 150,160" fill="#4a9fd4" />
+                <polygon points="65,160 148,75 200,160" fill="#5ab2dc" />
+              </svg>
+              <div v-if="!isView" class="avatar-tip">点击上传头像</div>
+            </div>
+          </el-upload>
+        </div>
+
+        <!-- 右侧:表单字段 -->
+        <div class="form-fields">
+          <el-form
+            ref="userForm"
+            :model="form"
+            :rules="isView ? {} : formRules"
+            :disabled="isView"
+            label-width="80px"
+            size="small"
+          >
+            <!-- 行1:姓名 / 性别 -->
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="姓名" prop="name">
+                  <el-input v-model="form.name" placeholder="姓名" />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="性别">
+                  <el-radio-group v-model="form.gender">
+                    <el-radio label="male">男</el-radio>
+                    <el-radio label="female">女</el-radio>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+            </el-row>
+
+            <!-- 行2:手机号码 / 工号 -->
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="手机号码" prop="phone">
+                  <el-input v-model="form.phone" placeholder="手机号码" />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="工号" prop="workNo">
+                  <el-input v-model="form.workNo" placeholder="工号" />
+                </el-form-item>
+              </el-col>
+            </el-row>
+
+            <!-- 行3:角色 / 部门 -->
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="角色" prop="role">
+                  <el-select v-model="form.role" placeholder="请选择角色" style="width:100%">
+                    <el-option v-for="r in roles" :key="r" :label="r" :value="r" />
+                  </el-select>
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="部门" prop="dept">
+                  <el-select v-model="form.dept" placeholder="请选择部门" style="width:100%">
+                    <el-option v-for="d in depts" :key="d" :label="d" :value="d" />
+                  </el-select>
+                </el-form-item>
+              </el-col>
+            </el-row>
+
+            <!-- 详情模式额外展示:状态 / 创建时间 -->
+            <template v-if="isView">
+              <el-row :gutter="20">
+                <el-col :span="12">
+                  <el-form-item label="状态">
+                    <el-tag :type="form.status === 1 ? 'success' : 'danger'" size="small">
+                      {{ form.status === 1 ? '启用' : '禁用' }}
+                    </el-tag>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="12">
+                  <el-form-item label="创建时间">
+                    <el-input :value="form.createdAt" disabled />
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+          </el-form>
+        </div>
+      </div>
+
+      <!-- 底部按钮 -->
+      <div class="form-footer">
+        <el-button size="small" @click="$router.push('/user')">取 消</el-button>
+        <el-button
+          v-if="!isView"
+          type="primary"
+          size="small"
+          :loading="saving"
+          @click="handleSave"
+        >保 存</el-button>
+        <el-button
+          v-if="isView"
+          type="primary"
+          size="small"
+          @click="$router.push(`/user/form?id=${form.id}&mode=edit`)"
+        >编 辑</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getUserDetail, saveUser, USER_ROLES, USER_DEPTS } from '@/api/user'
+
+const DEFAULT_FORM = () => ({
+  id: null,
+  name: '',
+  gender: 'male',
+  phone: '',
+  workNo: '',
+  role: '',
+  dept: '',
+  status: 1,
+  createdAt: '',
+  avatar: ''
+})
+
+export default {
+  name: 'UserForm',
+  data() {
+    return {
+      saving: false,
+      form: DEFAULT_FORM(),
+      roles: USER_ROLES,
+      depts: USER_DEPTS,
+      formRules: {
+        name:  [{ required: true, message: '请输入姓名',   trigger: 'blur' }],
+        phone: [
+          { required: true, message: '请输入手机号码', trigger: 'blur' },
+          { pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' }
+        ],
+        workNo: [{ required: true, message: '请输入工号', trigger: 'blur' }],
+        role:   [{ required: true, message: '请选择角色', trigger: 'change' }],
+        dept:   [{ required: true, message: '请选择部门', trigger: 'change' }]
+      }
+    }
+  },
+  computed: {
+    mode()      { return this.$route.query.mode || 'add' },
+    isView()    { return this.mode === 'view' },
+    userId()    { return this.$route.query.id },
+    pageTitle() {
+      return { add: '新增用户', edit: '编辑用户', view: '用户详情' }[this.mode] || '新增用户'
+    }
+  },
+  created() {
+    if (this.userId) this.loadDetail()
+  },
+  methods: {
+    async loadDetail() {
+      const res = await getUserDetail(this.userId)
+      this.form = { ...DEFAULT_FORM(), ...res.data }
+    },
+
+    handleAvatarChange(file) {
+      // 读取本地图片预览(不上传后端)
+      const reader = new FileReader()
+      reader.onload = e => { this.form.avatar = e.target.result }
+      reader.readAsDataURL(file.raw)
+    },
+
+    handleSave() {
+      this.$refs.userForm.validate(async valid => {
+        if (!valid) return
+        this.saving = true
+        try {
+          await saveUser(this.form)
+          this.$message.success(this.mode === 'add' ? '新增成功' : '保存成功')
+          this.$router.push('/user')
+        } finally {
+          this.saving = false
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.user-form {
+  .page-header {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    margin-bottom: 16px;
+
+    .page-title {
+      font-size: 16px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  .form-body {
+    display: flex;
+    gap: 32px;
+
+    // 左侧头像区
+    .avatar-section {
+      flex-shrink: 0;
+
+      .avatar-box {
+        width: 180px;
+        border: 1px solid #dcdfe6;
+        border-radius: 4px;
+        overflow: hidden;
+        cursor: pointer;
+        position: relative;
+
+        &:hover .avatar-tip { opacity: 1; }
+
+        .avatar-img {
+          width: 180px;
+          height: 140px;
+          object-fit: cover;
+          display: block;
+        }
+
+        .avatar-placeholder {
+          width: 180px;
+          height: 140px;
+          display: block;
+        }
+
+        .avatar-tip {
+          position: absolute;
+          bottom: 0;
+          left: 0;
+          right: 0;
+          background: rgba(0, 0, 0, 0.45);
+          color: #fff;
+          font-size: 12px;
+          text-align: center;
+          padding: 4px 0;
+          opacity: 0;
+          transition: opacity 0.2s;
+        }
+      }
+    }
+
+    // 右侧表单
+    .form-fields {
+      flex: 1;
+      min-width: 0;
+    }
+  }
+
+  // 底部按钮居中
+  .form-footer {
+    display: flex;
+    justify-content: center;
+    gap: 16px;
+    padding-top: 20px;
+    border-top: 1px solid #f0f0f0;
+    margin-top: 16px;
+  }
+}
+</style>

+ 331 - 0
src/views/user/index.vue

@@ -0,0 +1,331 @@
+<template>
+  <div class="user-page">
+    <!-- 左侧部门树 -->
+    <div class="panel-left">
+      <div class="tree-search">
+        <el-input
+          v-model="treeKeyword"
+          placeholder="请输入姓名"
+          size="small"
+          prefix-icon="el-icon-search"
+          clearable
+          @clear="clearTreeFilter"
+        />
+        <el-button type="primary" size="small" @click="handleTreeSearch">查询</el-button>
+      </div>
+      <el-tree
+        ref="deptTree"
+        :data="treeData"
+        :props="{ label: 'label', children: 'children' }"
+        :filter-node-method="filterTreeNode"
+        highlight-current
+        default-expand-all
+        @node-click="handleNodeClick"
+      />
+    </div>
+
+    <!-- 右侧内容区 -->
+    <div class="panel-right">
+      <!-- 搜索工具栏 -->
+      <el-card shadow="never" class="search-card">
+        <div class="toolbar">
+          <el-input
+            v-model="searchForm.keyword"
+            placeholder="姓名/工号"
+            size="small"
+            style="width:160px"
+            clearable
+            @clear="handleSearch"
+          />
+          <el-select v-model="searchForm.role" placeholder="角色" size="small" clearable style="width:120px">
+            <el-option v-for="r in roles" :key="r" :label="r" :value="r" />
+          </el-select>
+          <el-select v-model="searchForm.status" placeholder="状态" size="small" clearable style="width:90px">
+            <el-option label="启用" :value="1" />
+            <el-option label="禁用" :value="0" />
+          </el-select>
+          <el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">查询</el-button>
+          <el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button>
+          <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd">新建</el-button>
+        </div>
+      </el-card>
+
+      <!-- 数据表格 -->
+      <el-card shadow="never" class="table-card">
+        <el-table
+          v-loading="loading"
+          :data="pageData"
+          border
+          size="small"
+          style="width:100%"
+        >
+          <el-table-column label="姓名" min-width="90">
+            <template slot-scope="{ row }">
+              <span class="name-text" @click="handleDetail(row)">{{ row.name }}</span>
+            </template>
+          </el-table-column>
+
+          <el-table-column label="工号" min-width="120">
+            <template slot-scope="{ row }">
+              <span class="workno-text">{{ row.workNo }}</span>
+            </template>
+          </el-table-column>
+
+          <el-table-column label="角色"     prop="role" min-width="110" show-overflow-tooltip />
+          <el-table-column label="所在部门" prop="dept" min-width="120" show-overflow-tooltip />
+
+          <el-table-column label="状态" width="75" align="center">
+            <template slot-scope="{ row }">
+              <el-switch
+                v-model="row.status"
+                :active-value="1"
+                :inactive-value="0"
+                active-color="#409EFF"
+              />
+            </template>
+          </el-table-column>
+
+          <el-table-column label="创建时间" prop="createdAt" min-width="155" />
+
+          <el-table-column label="操作" width="120" align="center" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button type="text" size="mini" @click="handleDetail(row)">详情</el-button>
+              <el-divider direction="vertical" />
+              <el-dropdown size="mini" trigger="click" @command="cmd => handleCommand(cmd, row)">
+                <el-button type="text" size="mini">更多<i class="el-icon-arrow-right el-icon--right"></i></el-button>
+                <el-dropdown-menu slot="dropdown">
+                  <el-dropdown-item command="edit">编辑</el-dropdown-item>
+                  <el-dropdown-item command="delete" style="color:#F56C6C">删除</el-dropdown-item>
+                </el-dropdown-menu>
+              </el-dropdown>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <!-- 分页 -->
+        <div class="pagination-wrapper">
+          <span class="pagination-info">共{{ total }}条记录 第{{ currentPage }}/{{ totalPages }}页</span>
+          <el-pagination
+            background
+            :current-page="currentPage"
+            :page-sizes="[10, 20]"
+            :page-size="pageSize"
+            :total="total"
+            layout="prev, pager, next, sizes, jumper"
+            @size-change="handleSizeChange"
+            @current-change="handleCurrentChange"
+          />
+        </div>
+      </el-card>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getUserList, USER_ROLES } from '@/api/user'
+
+export default {
+  name: 'UserManagement',
+  data() {
+    return {
+      loading: false,
+      allList: [],
+      filteredList: [],
+      total: 0,
+      currentPage: 1,
+      pageSize: 10,
+
+      // 左侧树
+      treeKeyword: '',
+      selectedDept: '',
+      treeData: [
+        {
+          label: '安科院',
+          children: [
+            { label: '部门1', children: [{ label: '一组' }, { label: '二组' }] },
+            { label: '部门2', children: [{ label: '一组' }, { label: '二组' }] }
+          ]
+        }
+      ],
+
+      // 右侧搜索
+      searchForm: { keyword: '', role: '', status: '' },
+      roles: USER_ROLES
+    }
+  },
+  computed: {
+    pageData() {
+      const start = (this.currentPage - 1) * this.pageSize
+      return this.filteredList.slice(start, start + this.pageSize)
+    },
+    totalPages() {
+      return Math.ceil(this.total / this.pageSize) || 1
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      this.loading = true
+      try {
+        const res = await getUserList()
+        this.allList = res.data.list
+        this.applyFilters()
+      } finally {
+        this.loading = false
+      }
+    },
+
+    // 综合过滤:树节点选中部门 + 搜索表单
+    applyFilters() {
+      let list = [...this.allList]
+
+      // 树节点按部门过滤(叶子节点匹配全路径,父节点匹配前缀)
+      if (this.selectedDept && this.selectedDept !== '安科院') {
+        list = list.filter(u => u.dept.includes(this.selectedDept))
+      }
+
+      const { keyword, role, status } = this.searchForm
+      if (keyword) list = list.filter(u => u.name.includes(keyword) || u.workNo.includes(keyword))
+      if (role)    list = list.filter(u => u.role === role)
+      if (status !== '') list = list.filter(u => u.status === status)
+
+      this.filteredList = list
+      this.total = list.length
+      this.currentPage = 1
+    },
+
+    handleSearch() { this.applyFilters() },
+
+    handleReset() {
+      this.searchForm = { keyword: '', role: '', status: '' }
+      this.selectedDept = ''
+      this.$refs.deptTree.setCurrentKey(null)
+      this.applyFilters()
+    },
+
+    // el-tree 节点文本过滤方法
+    filterTreeNode(value, data) {
+      if (!value) return true
+      return data.label.includes(value)
+    },
+
+    handleTreeSearch() {
+      this.$refs.deptTree.filter(this.treeKeyword)
+    },
+
+    clearTreeFilter() {
+      this.$refs.deptTree.filter('')
+    },
+
+    // 点击树节点:按部门过滤表格
+    handleNodeClick(data) {
+      this.selectedDept = data.label
+      this.applyFilters()
+    },
+
+    handleAdd()        { this.$router.push('/user/form') },
+    handleDetail(row)  { this.$router.push(`/user/form?id=${row.id}&mode=view`) },
+
+    handleCommand(cmd, row) {
+      if (cmd === 'edit') {
+        this.$router.push(`/user/form?id=${row.id}&mode=edit`)
+      } else if (cmd === 'delete') {
+        this.$confirm(`确认删除用户「${row.name}」?`, '删除确认', {
+          confirmButtonText: '确认删除',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }).then(() => {
+          this.allList = this.allList.filter(u => u.id !== row.id)
+          this.applyFilters()
+          this.$message.success(`用户「${row.name}」已删除`)
+        }).catch(() => {})
+      }
+    },
+
+    handleSizeChange(size) { this.pageSize = size; this.currentPage = 1 },
+    handleCurrentChange(page) { this.currentPage = page }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.user-page {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+
+  .panel-left {
+    width: 200px;
+    flex-shrink: 0;
+    background: #fff;
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    padding: 12px;
+
+    .tree-search {
+      display: flex;
+      gap: 6px;
+      margin-bottom: 12px;
+    }
+
+    ::v-deep .el-tree-node__content {
+      height: 32px;
+      font-size: 13px;
+    }
+
+    ::v-deep .el-tree-node.is-current > .el-tree-node__content {
+      background-color: #ecf5ff;
+      color: #409eff;
+      font-weight: 500;
+    }
+  }
+
+  .panel-right {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+
+    .search-card {
+      ::v-deep .el-card__body { padding: 12px 16px; }
+
+      .toolbar {
+        display: flex;
+        align-items: center;
+        flex-wrap: wrap;
+        gap: 8px;
+      }
+    }
+
+    .table-card {
+      .name-text {
+        color: #e6a23c;
+        cursor: pointer;
+        font-weight: 500;
+        &:hover { text-decoration: underline; }
+      }
+
+      .workno-text {
+        color: #409eff;
+      }
+
+      .pagination-wrapper {
+        display: flex;
+        align-items: center;
+        justify-content: flex-end;
+        margin-top: 16px;
+        gap: 16px;
+
+        .pagination-info {
+          font-size: 13px;
+          color: #606266;
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+}
+</style>

+ 6 - 0
vue.config.js

@@ -0,0 +1,6 @@
+const { defineConfig } = require('@vue/cli-service')
+
+module.exports = defineConfig({
+  transpileDependencies: true,
+  lintOnSave: false
+})