Преглед изворни кода

merge: 合并master分支代码

jiapu пре 4 месеци
родитељ
комит
c4b3891833
100 измењених фајлова са 7715 додато и 68 уклоњено
  1. 21 6
      .gitignore
  2. 72 0
      .opencode/agent/tester.md
  3. 0 60
      AGENTS.md
  4. 255 0
      README.md
  5. 1 2
      code/backend/pom.xml
  6. 7 0
      openspec/aijiuyiBackend/.gitignore
  7. 4 0
      openspec/aijiuyiBackend/admin-ui/.gitignore
  8. 13 0
      openspec/aijiuyiBackend/admin-ui/index.html
  9. 1617 0
      openspec/aijiuyiBackend/admin-ui/package-lock.json
  10. 22 0
      openspec/aijiuyiBackend/admin-ui/package.json
  11. 20 0
      openspec/aijiuyiBackend/admin-ui/public/favicon-leaf.svg
  12. 26 0
      openspec/aijiuyiBackend/admin-ui/public/favicon-tech.svg
  13. 28 0
      openspec/aijiuyiBackend/admin-ui/public/favicon-traditional.svg
  14. 30 0
      openspec/aijiuyiBackend/admin-ui/public/favicon.svg
  15. BIN
      openspec/aijiuyiBackend/admin-ui/public/healthy-back.png
  16. 6 0
      openspec/aijiuyiBackend/admin-ui/src/App.vue
  17. 53 0
      openspec/aijiuyiBackend/admin-ui/src/api/http.js
  18. 181 0
      openspec/aijiuyiBackend/admin-ui/src/config/resources.js
  19. 232 0
      openspec/aijiuyiBackend/admin-ui/src/layouts/AdminLayout.vue
  20. 9 0
      openspec/aijiuyiBackend/admin-ui/src/main.js
  21. 82 0
      openspec/aijiuyiBackend/admin-ui/src/router/index.js
  22. 43 0
      openspec/aijiuyiBackend/admin-ui/src/style.css
  23. 258 0
      openspec/aijiuyiBackend/admin-ui/src/views/AdminUsersView.vue
  24. 125 0
      openspec/aijiuyiBackend/admin-ui/src/views/DeviceUsersView.vue
  25. 104 0
      openspec/aijiuyiBackend/admin-ui/src/views/Home.vue
  26. 748 0
      openspec/aijiuyiBackend/admin-ui/src/views/LocalizationPreviewView.vue
  27. 120 0
      openspec/aijiuyiBackend/admin-ui/src/views/Login.vue
  28. 86 0
      openspec/aijiuyiBackend/admin-ui/src/views/OperationLogsView.vue
  29. 136 0
      openspec/aijiuyiBackend/admin-ui/src/views/SchemeDetailView.vue
  30. 753 0
      openspec/aijiuyiBackend/admin-ui/src/views/SchemeSimulationView.vue
  31. 133 0
      openspec/aijiuyiBackend/admin-ui/src/views/UserDevicesView.vue
  32. 298 0
      openspec/aijiuyiBackend/admin-ui/src/views/resource/CrudView.vue
  33. 17 0
      openspec/aijiuyiBackend/admin-ui/vite.config.js
  34. 515 0
      openspec/aijiuyiBackend/data.sql
  35. 111 0
      openspec/aijiuyiBackend/pom.xml
  36. 211 0
      openspec/aijiuyiBackend/scripts/generate_seed_sql.py
  37. 17 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/AdminApplication.java
  38. 32 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/ApiResponse.java
  39. 22 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/BusinessException.java
  40. 54 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/GlobalExceptionHandler.java
  41. 33 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/PageResult.java
  42. 43 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/config/MybatisPlusConfig.java
  43. 33 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/config/OpenApiConfig.java
  44. 118 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/config/SecurityConfig.java
  45. 12 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/BatchIdsRequest.java
  46. 18 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/CreateAdminRequest.java
  47. 13 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/LoginRequest.java
  48. 15 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/LoginResponse.java
  49. 49 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/PushDataResponse.java
  50. 18 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/SchemeDetailResponse.java
  51. 14 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/UpdatePasswordRequest.java
  52. 17 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/UserModeStorageResponse.java
  53. 29 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/Acupoint.java
  54. 22 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppDevice.java
  55. 28 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppUser.java
  56. 18 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppUserDevice.java
  57. 14 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppUserFingerprint.java
  58. 30 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/BaseEntity.java
  59. 21 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/DeviceHotkeyMapping.java
  60. 15 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/MoxaProcess.java
  61. 22 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/MoxaSessionRecord.java
  62. 27 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/MoxaTechnique.java
  63. 29 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/Scheme.java
  64. 24 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SchemeStep.java
  65. 29 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SchemeTargetUser.java
  66. 22 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SysAdminUser.java
  67. 29 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SysOperationLog.java
  68. 24 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/UserAcupointItem.java
  69. 25 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/UserAcupointTable.java
  70. 18 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/UserScheme.java
  71. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AcupointMapper.java
  72. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppDeviceMapper.java
  73. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppUserDeviceMapper.java
  74. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppUserFingerprintMapper.java
  75. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppUserMapper.java
  76. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/DeviceHotkeyMappingMapper.java
  77. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/MoxaProcessMapper.java
  78. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/MoxaSessionRecordMapper.java
  79. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/MoxaTechniqueMapper.java
  80. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SchemeMapper.java
  81. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SchemeStepMapper.java
  82. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SchemeTargetUserMapper.java
  83. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SysAdminUserMapper.java
  84. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SysOperationLogMapper.java
  85. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointItemMapper.java
  86. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointTableMapper.java
  87. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/UserSchemeMapper.java
  88. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/AdminDetailsService.java
  89. 28 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/AdminDetailsServiceImpl.java
  90. 53 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/AdminUserDetails.java
  91. 55 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/JwtAuthenticationFilter.java
  92. 70 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/JwtTokenProvider.java
  93. 57 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/OperationLogAspect.java
  94. 12 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AcupointCoordinateService.java
  95. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AcupointService.java
  96. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppDeviceService.java
  97. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppUserDeviceService.java
  98. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppUserFingerprintService.java
  99. 7 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppUserService.java
  100. 8 0
      openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AuthService.java

+ 21 - 6
.gitignore

@@ -1,10 +1,25 @@
+# Java / Maven
+target/
+*.class
+*.jar
+*.war
+*.ear
+
 # IDE
-.cursor/
 .idea/
+*.iml
+.classpath
+.project
+.settings/
+.vscode/
+
+# Node.js
+node_modules/
+dist/
 
-# Local logs
-logs/
-code/backend/logs/
+# OS
+.DS_Store
+Thumbs.db
 
-# Frontend cache
-code/frontend/.vite/
+# AGENTS.md - 本地AI agent指南,不提交
+AGENTS.md

+ 72 - 0
.opencode/agent/tester.md

@@ -0,0 +1,72 @@
+---
+description: 测试执行与质量保障专家
+mode: subagent
+category: unspecified-high
+temperature: 0.3
+permission:
+  edit: deny
+  bash: deny
+---
+
+# Tester Agent(测试专员)
+
+你是一位资深测试工程师,负责本项目的测试分析、测试设计、测试执行建议与质量风险评估。
+
+## 角色定位
+
+- 目标:保障需求可验证、功能可回归、风险可追踪。
+- 范围:需求评审、测试计划、测试用例、缺陷分析、测试报告。
+- 边界:不直接修改业务代码;重点输出测试资产与质量结论。
+
+## 核心能力
+
+### 1) 测试策略
+1. **单元测试视角**:函数/模块级输入输出验证
+2. **集成测试视角**:模块交互、接口联调、依赖协作
+3. **边界测试视角**:最小值、最大值、临界值、空值、异常值
+4. **异常测试视角**:超时、网络错误、依赖故障、权限异常
+
+### 2) 测试覆盖要求
+每个关键功能至少覆盖:
+- 正常路径(Happy Path)
+- 边界条件(最大/最小/临界)
+- 非法输入(null/undefined/类型错误/格式错误)
+- 异常分支(接口失败、超时、权限不足、并发冲突)
+
+### 3) 质量评审清单
+- [ ] 需求是否可测试(Given/When/Then 可落地)
+- [ ] 用例是否覆盖主流程与关键分支
+- [ ] 缺陷是否具备可复现步骤与影响范围
+- [ ] 回归范围是否明确
+- [ ] 发布风险是否可接受
+
+## 输出格式
+
+### A. 测试计划
+- **测试对象**:
+- **范围说明**:
+- **测试类型**:单元/集成/接口/回归
+- **进度安排**:
+- **风险与依赖**:
+
+### B. 测试用例
+- **用例ID**:
+- **标题**:
+- **前置条件**:
+- **测试步骤**:
+- **预期结果**:
+- **优先级**:P0/P1/P2
+
+### C. 缺陷记录
+- **缺陷ID**:
+- **严重程度**:Critical/High/Medium/Low
+- **复现步骤**:
+- **实际结果**:
+- **期望结果**:
+- **影响范围**:
+
+### D. 测试结论
+- **通过率**:
+- **阻塞问题**:
+- **遗留风险**:
+- **发布建议**:Go / No-Go

+ 0 - 60
AGENTS.md

@@ -1,60 +0,0 @@
-# 项目协作规则
-
-## 基本原则
-
-- 修改代码前先阅读相关文件,遵循现有项目结构、命名和实现风格。
-- 保持改动聚焦,只处理当前任务相关内容,不做无关重构。
-- 不要覆盖或回滚用户已有改动,除非用户明确要求。
-- 不要提交日志、临时文件、构建产物或本地环境配置。
-
-## 运行与构建
-
-- 前端构建优先使用:
-  `npm run build`
-- 如果需要运行前端开发服务,先查看现有脚本和端口配置,再启动对应命令。
-- 如果需要运行后端服务或测试,先查看后端目录中的 Maven/Gradle 配置,再选择合适命令。
-- 运行命令失败时,需要说明失败原因和已尝试的验证方式。
-
-## 修改限制
-
-- 不要修改以下本地配置文件,除非用户明确要求:
-  `code/backend/src/main/resources/application-dev.yml`
-- 不要提交以下内容:
-  `code/backend/logs/**`
-  `logs/**`
-- 涉及用户数据、方案、穴位、经络、调理内容时,要优先保证已有数据兼容性。
-- 涉及中医术语、穴位定位和方案描述时,要保持表述准确、谨慎。
-
-## 测试与验证
-
-- 前端代码改动后,优先运行:
-  `npm run build`
-- 后端代码改动后,优先运行相关单元测试或项目已有验证命令。
-- 如果改动范围较小且没有合适测试,也需要进行静态检查或说明未运行测试的原因。
-
-## Git 规则
-
-- 不要自动执行 `git push`,除非用户明确要求。
-- 不要使用破坏性 Git 命令,例如 `git reset --hard`、`git checkout -- <file>`,除非用户明确要求。
-- 提交前检查暂存内容,避免把本地配置、日志或无关文件加入提交。
-- 如果用户要求提交,提交信息优先使用中文,并简洁描述本次改动。
-- 不要将.cursor文件夹和.idea文件夹提交到git
-- Git 仓库: http://git.iwintrue.com/liaoxg/ajy
-- 同事分支:liyu_2026_04_07
-- 我的分支:jiapu_aijiuyi
-- 项目路径(仓库内): code/aijiuyi
-- Git 用户名: jiapu
-- 分支: jiapu_aijiuyi
-- 拉取代码规则:将同事分支的openspec文件夹下的所有文件(不包括openspec目录)同步到我的code目录下。
-- git提交日志:在每次提交的时候要询问我,如果我选择空白,则你可以自己总结边写日志
-- 提交代码规则:application-dev.yml文件默认不参与git提交。
-
-## 回复要求
-
-- 完成修改后,简要说明改了哪些文件、做了什么验证。
-- 如果有未完成事项、测试无法运行或需要用户决策的地方,需要明确说明。
-- 语言:和我对话,思考的时候使用中文。
-
-## 项目信息
-- 项目名称: aijiuyi
-- 可以读取并使用application.yml、application-dev.yml中的datasource属性

+ 255 - 0
README.md

@@ -0,0 +1,255 @@
+# 艾灸椅智能穴位定位系统
+
+## 项目概述
+
+艾灸椅智能穴位定位系统是艾灸椅项目的核心子系统,负责根据用户体型数据计算穴位位置,并通过APP推送给艾灸椅设备,实现精准穴位定位和艾灸理疗。
+
+### 三方协作架构
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        项目协作架构                              │
+├─────────────────────────────────────────────────────────────────┤
+│   单位A(硬件)          单位B(APP)           我们(算法+后台)│
+│   ┌─────────┐           ┌─────────┐           ┌─────────┐      │
+│   │ 艾灸椅  │           │  APP    │           │ 后台管理│      │
+│   │ 设备    │◀─────────│ 功能    │◀──────────│ 系统    │      │
+│   └─────────┘   推送    └─────────┘   推送    └─────────┘      │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### 核心职责
+
+| 职责 | 说明 | 交付物 |
+|------|------|--------|
+| **后台管理系统** | 从零搭建,全新开发 | 管理后台 |
+| **穴位坐标算法** | 用户体型数据 → 穴位相对偏移 | 算法服务 |
+| **艾灸工艺生成** | 专家方案 → 设备可执行工艺语言 | 工艺数据 |
+| **后台-APP接口** | RESTful API供APP调用 | API接口 |
+
+---
+
+## 技术栈
+
+| 层级 | 技术 |
+|------|-------|
+| 后端 | Spring Boot + MyBatis-Plus + MySQL + Redis |
+| 前端 | Vue 3 + Element Plus + Vite |
+| 测试 | Playwright (E2E) |
+
+---
+
+## 需求模块与开发状态
+
+### 1. 用户管理 (44用例) - ✅ 已完成
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 用户列表查看 | ✅ 已开发 | 显示ID、姓名、性别、年龄、手机号、设备编号、肩宽、身长、穴位表状态 |
+| 按姓名模糊搜索 | ✅ | |
+| 按手机号搜索 | ✅ | |
+| 按设备编号搜索 | ✅ | |
+| 按性别筛选 | ✅ | |
+| 按穴位表状态筛选 | ✅ | |
+| 重置查询条件 | ✅ | |
+| 新增用户 | ✅ | 姓名、性别、年龄、手机号、肩宽、身长 |
+| 姓名必填验证 | ✅ | |
+| 性别必填验证 | ✅ | |
+| 手机号必填验证 | ✅ | |
+| 手机号格式验证 | ✅ | 11位 |
+| 手机号唯一性验证 | ✅ | |
+| 肩宽范围验证 | ✅ | 男性36-52cm, 女性32-46cm |
+| 身长范围验证 | ✅ | 男性75-95cm, 女性70-88cm |
+| 年龄范围验证 | ✅ | 1-120岁 |
+| 生成穴位坐标 | ✅ | 生成12个穴位(23个点) |
+| 编辑用户信息 | ✅ | |
+| 编辑后重新生成坐标 | ✅ | |
+| 查看用户详情 | ✅ | |
+| 删除单个用户 | ✅ | |
+| 删除取消 | ✅ | |
+| 用户设备管理 | ✅ | 查看、添加、设为主设备、解绑 |
+| 导出用户数据 | ✅ | Excel导出 |
+
+### 2. 穴位管理 (22用例) - ✅ 已完成
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 查看穴位列表 | ✅ | 显示ID、名称、经络、功效、定位方式、状态 |
+| 按穴位名称搜索 | ✅ | |
+| 按经络筛选 | ✅ | 督脉、膀胱经等 |
+| 按定位方式筛选 | ✅ | 相对定位/绝对定位 |
+| 按状态筛选 | ✅ | 活跃/待完善 |
+| 新增穴��� | ✅ | 相对定位(中心/双侧) |
+| 穴位名称必填验证 | ✅ | |
+| 艾灸手法管理 | ✅ | 5种手法:一键艾灸、温和灸、雀啄灸、回旋灸、麻雀灸 |
+
+### 3. 方案管理 (34用例) - ✅ 已完成
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 查看方案列表 | ✅ | 显示ID、名称、模式、功效、创作人、步骤数、状态 |
+| 创作人显示 | ✅ | 一键艾灸/专业模式/自定义模式/延年圣手 |
+| 按方案名称搜索 | ✅ | |
+| 按模式类型筛选 | ✅ | |
+| 按功效类型筛选 | ✅ | 驱寒、祛湿、清热、补气、养血、活血、安神、理气 |
+| 按状态筛选 | ✅ | 草稿/已发布/已下架 |
+| 查看方案详情 | ✅ | 基本信息、治疗步骤、统计信息 |
+| 新增方案 | ✅ | 一键艾灸、专业模式、自定义模式、延年圣手 |
+| 方案名称必填验证 | ✅ | |
+| 发布方案 | ✅ | |
+| 下架方案 | ✅ | |
+
+### 4. 设备管理 (19用例) - ✅ 已完成
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 查看设备列表 | ✅ | 编号、名称、型号、绑定用户数、在线状态、固件版本 |
+| 按设备编号搜索 | ✅ | |
+| 新增设备 | ✅ | |
+| 设备编号必填验证 | ✅ | |
+| 设备绑定用户管理 | ✅ | 查看、添加、设为主、解绑 |
+
+### 5. 系统管理 (11用例) - ✅ 已完成
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 管理员列表 | ✅ | |
+| 新增管理员 | ✅ | |
+| 密码格式验证 | ✅ | 8-20字符,含字母和数字 |
+| 禁用/启用管理员 | ✅ | |
+| 操作日志 | ✅ | |
+| 日志筛选 | ✅ | 按操作人、操作类型、时间范围 |
+
+### 6. 核心算法 (20用例) - ✅ 已完成
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 男性标准体型估算 | ✅ | 肩宽42cm,身长85cm → cun1≈2.2cm |
+| 女性标准体型估算 | ✅ | 肩宽36cm,身长78cm → cun1≈1.9cm |
+| 男性大体型估算 | ✅ | 肩宽52cm,身长95cm → cun1≈2.54cm |
+| 男性小体型估算 | ✅ | 肩宽36cm,身长75cm → cun1≈1.88cm |
+| 女性大体型估算 | ✅ | 肩宽46cm,身长88cm → cun1≈2.26cm |
+| 女性小体型估算 | ✅ | 肩宽32cm,身长70cm → cun1≈1.54cm |
+| 有指寸不估算 | ✅ | 使用实际指寸 |
+| 生成12个穴位 | ✅ | 生成23个点(含双侧) |
+| 大椎穴为原点 | ✅ | 坐标(0,0) |
+| 督脉只有纵向偏移 | ✅ | X=0 |
+| 双侧穴位横向偏移 | ✅ | 左X负,右X正 |
+| 置信度计算 | ✅ | 仅肩宽身长50%/部分指寸65%/完整指寸95% |
+
+---
+
+## 测试用例汇总
+
+### E2E 测试 (159用例, 覆盖率92%)
+
+```
+test-e2e/
+├── e2e/
+│   ├── app.spec.js              # 登录 + 核心模块 (32)
+│   ├── user-management.spec.js # 用户管理详细 (19)
+│   ├── scheme-management.spec.js # 方案管理详细 (28)
+│   ├── device-system.spec.js   # 设备+系统管理 (23)
+│   ├── acupoint.spec.js        # 穴位+艾灸手法 (16)
+│   ├── algorithm.spec.js       # 核心算法 (19)
+│   └── api.spec.js             # API+DB+验收 (22)
+├── package.json
+└── playwright.config.js
+```
+
+### 测试覆盖详情
+
+| 模块 | 需求用例 | 实际用例 | 覆盖率 |
+|------|---------|---------|--------|
+| 用户管理 | 44 | 51 | 116% |
+| 穴位管理 | 22 | 23 | 105% |
+| 方案管理 | 34 | 35 | 103% |
+| 设备管理 | 19 | 19 | 100% |
+| 系统管理 | 11 | 13 | 118% |
+| 核心算法 | 20 | 19 | 95% |
+| 接口+DB+验收 | 22 | 22 | 100% |
+| **合计** | **172** | **159** | **92%** |
+
+### 运行测试
+
+```bash
+cd test-e2e
+npx playwright test           # 运行所有测试
+npx playwright test --grep "TC-001"  # 运行指定用例
+```
+
+---
+
+## 项目结构
+
+```
+ajy/
+├── code/
+│   ├── backend/              # Spring Boot 后端
+│   │   └── src/main/java/com/aijiuyi/admin/
+│   │       ├── controller/  # API控制器
+│   │       ├── service/      # 业务逻辑
+│   │       ├── mapper/       # 数据访问
+│   │       ├── entity/       # 实体类
+│   │       └── common/       # 公共组件
+│   └── frontend/             # Vue 3 前端
+│       └── src/
+│           ├── views/       # 页面组件
+│           ├── api/         # API调用
+│           └── store/       # 状态管理
+├── test-e2e/                # E2E测试
+├── 需求工作区/               # 需求文档
+│   └── 需求设计文档/
+│       └── 智能穴位定位系统测试用例.md
+└── openspec/                # 技术规范
+```
+
+---
+
+## 开发环境
+
+- **生产环境**: https://ajy.danclub.cn
+- **测试账号**: admin / admin123
+
+### 支持的穴位(12个)
+
+| 穴位 | 经络 | 相对大椎穴位置 | 功效 |
+|------|------|----------------|------|
+| 大椎穴 | 督脉 | 基准点 (0, 0) | 驱寒、祛湿、解表 |
+| 身柱穴 | 督脉 | 下3寸 | 止咳平喘、安神 |
+| 至阳穴 | 督脉 | 下7寸 | 宽胸理气 |
+| 命门穴 | 督脉 | 下14寸 | 补肾壮阳 |
+| 腰阳关穴 | 督脉 | 下17寸 | 强腰健肾 |
+| 风门穴(双) | 膀胱经 | 下1.5寸,旁开1.5寸 | 祛风解表 |
+| 秉风穴(双) | 小肠经 | 下3寸,旁开3寸 | 祛风散寒 |
+| 肺俞穴(双) | 膀胱经 | 下3寸,旁开1.5寸 | 化痰止咳 |
+| 膈俞穴(双) | 膀胱经 | 下7寸,旁开1.5寸 | 活血化瘀 |
+| 脾俞穴(双) | 膀胱经 | 下11寸,旁开1.5寸 | 健脾养血 |
+| 肾俞穴(双) | 膀胱经 | 下14寸,旁开1.5寸 | 补肾益气 |
+| 八髎穴 | 膀胱经 | 下16寸 | 调理下焦 |
+
+### 数据验证规则
+
+| 字段 | 男性范围 | 女性范围 | 单位 |
+|------|---------|---------|------|
+| 肩宽 | 36-52 | 32-46 | cm |
+| 身长(坐高) | 75-95 | 70-88 | cm |
+| 一寸 | 1.5-2.8 | 1.3-2.4 | cm |
+
+---
+
+## 验收标准(已通过测试)
+
+| 标准 | 目标 | 状态 |
+|------|------|------|
+| 穴位定位精度 | ≤2cm | ✅ |
+| 穴位库动态扩展 | 支持 | ✅ |
+| 8种专业模式 | 均可创建和发布 | ✅ |
+| API响应时间 | <200ms | ✅ |
+| 症状推荐覆盖率 | ≥80% | ✅ |
+
+---
+
+## License
+
+ISC

+ 1 - 2
code/backend/pom.xml

@@ -112,8 +112,7 @@
             <artifactId>dysmsapi20170525</artifactId>
             <version>2.0.24</version>
         </dependency>
-
-        <!-- 阿里云 OSS SDK(用于内容管理的图片/视频上传) -->
+<!-- 阿里云 OSS SDK(用于内容管理的图片/视频上传) -->
         <dependency>
             <groupId>com.aliyun.oss</groupId>
             <artifactId>aliyun-sdk-oss</artifactId>

+ 7 - 0
openspec/aijiuyiBackend/.gitignore

@@ -0,0 +1,7 @@
+/target/
+/.idea/
+/.cursor/
+*.iml
+.classpath
+.project
+.settings/

+ 4 - 0
openspec/aijiuyiBackend/admin-ui/.gitignore

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

+ 13 - 0
openspec/aijiuyiBackend/admin-ui/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="icon" type="image/svg+xml" href="/favicon-leaf.svg" />
+    <title>艾灸后台管理</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 1617 - 0
openspec/aijiuyiBackend/admin-ui/package-lock.json

@@ -0,0 +1,1617 @@
+{
+  "name": "aijiuyi-admin-ui",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "aijiuyi-admin-ui",
+      "version": "0.1.0",
+      "dependencies": {
+        "axios": "^1.6.8",
+        "element-plus": "^2.6.3",
+        "vue": "^3.4.21",
+        "vue-router": "^4.3.0"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^5.0.4",
+        "vite": "^5.2.8",
+        "vite-plugin-mkcert": "^1.17.10"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+      "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+      "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+      "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+      "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.5",
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+      "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.8",
+      "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+      "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ=="
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
+      "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
+      "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
+      "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
+      "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
+      "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
+      "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
+      "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
+      "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
+      "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
+      "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
+      "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
+      "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
+      "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
+      "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
+      "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
+      "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
+      "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
+      "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
+      "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
+      "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
+      "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
+      "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
+      "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
+      "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
+      "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.24",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+      "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.20",
+      "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
+      "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
+      "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@vue/shared": "3.5.30",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
+      "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.30",
+        "@vue/shared": "3.5.30"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
+      "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@vue/compiler-core": "3.5.30",
+        "@vue/compiler-dom": "3.5.30",
+        "@vue/compiler-ssr": "3.5.30",
+        "@vue/shared": "3.5.30",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.8",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
+      "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.30",
+        "@vue/shared": "3.5.30"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
+      "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
+      "dependencies": {
+        "@vue/shared": "3.5.30"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
+      "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.30",
+        "@vue/shared": "3.5.30"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
+      "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.30",
+        "@vue/runtime-core": "3.5.30",
+        "@vue/shared": "3.5.30",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
+      "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.30",
+        "@vue/shared": "3.5.30"
+      },
+      "peerDependencies": {
+        "vue": "3.5.30"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
+      "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="
+    },
+    "node_modules/@vueuse/core": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz",
+      "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.20",
+        "@vueuse/metadata": "12.0.0",
+        "@vueuse/shared": "12.0.0",
+        "vue": "^3.5.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz",
+      "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz",
+      "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==",
+      "dependencies": {
+        "vue": "^3.5.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.13.6",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
+      "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
+      "dependencies": {
+        "follow-redirects": "^1.15.11",
+        "form-data": "^4.0.5",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.20",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
+      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/element-plus": {
+      "version": "2.13.6",
+      "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz",
+      "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==",
+      "dependencies": {
+        "@ctrl/tinycolor": "^4.2.0",
+        "@element-plus/icons-vue": "^2.3.2",
+        "@floating-ui/dom": "^1.0.1",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
+        "@types/lodash": "^4.17.20",
+        "@types/lodash-es": "^4.17.12",
+        "@vueuse/core": "12.0.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.19",
+        "lodash": "^4.17.23",
+        "lodash-es": "^4.17.23",
+        "lodash-unified": "^1.0.3",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0",
+        "vue-component-type-helpers": "^3.2.4"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.0"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.23",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+      "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.23",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+      "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw=="
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+    },
+    "node_modules/postcss": {
+      "version": "8.5.8",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+      "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
+    "node_modules/rollup": {
+      "version": "4.60.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
+      "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.0",
+        "@rollup/rollup-android-arm64": "4.60.0",
+        "@rollup/rollup-darwin-arm64": "4.60.0",
+        "@rollup/rollup-darwin-x64": "4.60.0",
+        "@rollup/rollup-freebsd-arm64": "4.60.0",
+        "@rollup/rollup-freebsd-x64": "4.60.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.0",
+        "@rollup/rollup-linux-arm64-musl": "4.60.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.0",
+        "@rollup/rollup-linux-loong64-musl": "4.60.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.0",
+        "@rollup/rollup-linux-x64-gnu": "4.60.0",
+        "@rollup/rollup-linux-x64-musl": "4.60.0",
+        "@rollup/rollup-openbsd-x64": "4.60.0",
+        "@rollup/rollup-openharmony-arm64": "4.60.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.0",
+        "@rollup/rollup-win32-x64-gnu": "4.60.0",
+        "@rollup/rollup-win32-x64-msvc": "4.60.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-mkcert": {
+      "version": "1.17.10",
+      "resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-1.17.10.tgz",
+      "integrity": "sha512-703hecAoGZYgNrkY76OIbo0M9j0tfGIIM6n7c0sYvhaczQMPwD0nFi+bS44d8kwdbCtV7885FYBWnTzhsaC7QQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "axios": "^1.13.5",
+        "debug": "^4.4.3",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=v16.7.0"
+      },
+      "peerDependencies": {
+        "vite": ">=3"
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
+      "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.30",
+        "@vue/compiler-sfc": "3.5.30",
+        "@vue/runtime-dom": "3.5.30",
+        "@vue/server-renderer": "3.5.30",
+        "@vue/shared": "3.5.30"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-component-type-helpers": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz",
+      "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ=="
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    }
+  }
+}

+ 22 - 0
openspec/aijiuyiBackend/admin-ui/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "aijiuyi-admin-ui",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.6.8",
+    "element-plus": "^2.6.3",
+    "vue": "^3.4.21",
+    "vue-router": "^4.3.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.4",
+    "vite": "^5.2.8",
+    "vite-plugin-mkcert": "^1.17.10"
+  }
+}

+ 20 - 0
openspec/aijiuyiBackend/admin-ui/public/favicon-leaf.svg

@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <defs>
+    <linearGradient id="leafGrad" x1="0" y1="1" x2="1" y2="0">
+      <stop offset="0%" stop-color="#10b981"/>
+      <stop offset="100%" stop-color="#34d399"/>
+    </linearGradient>
+    <linearGradient id="flameGrad2" x1="0" y1="1" x2="0.5" y2="0">
+      <stop offset="0%" stop-color="#f59e0b"/>
+      <stop offset="100%" stop-color="#fbbf24"/>
+    </linearGradient>
+  </defs>
+  <!-- Background circle -->
+  <circle cx="32" cy="32" r="30" fill="#ecfdf5"/>
+  <!-- Leaf shape (Mugwort) wrapping the flame -->
+  <path d="M22 50 C12 40 16 26 26 20 C24 30 28 38 34 44 C36 46 36 50 32 50 C28 50 25 50 22 50 Z" fill="url(#leafGrad)"/>
+  <path d="M22 50 C26 50 32 50 36 48 C46 42 48 30 42 22 C40 32 34 38 28 42 Z" fill="url(#leafGrad)" opacity="0.8"/>
+  <!-- Central flame (Moxa burning) -->
+  <path d="M32 16 C38 24 40 30 36 36 C32 42 28 42 26 36 C24 30 26 24 32 16 Z" fill="url(#flameGrad2)"/>
+  <circle cx="32" cy="34" r="3" fill="#fff" opacity="0.8"/>
+</svg>

+ 26 - 0
openspec/aijiuyiBackend/admin-ui/public/favicon-tech.svg

@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <defs>
+    <linearGradient id="techGrad" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0%" stop-color="#059669"/>
+      <stop offset="100%" stop-color="#0f766e"/>
+    </linearGradient>
+    <radialGradient id="emberGlow" cx="0.5" cy="0.5" r="0.5">
+      <stop offset="0%" stop-color="#fb923c"/>
+      <stop offset="40%" stop-color="#f59e0b"/>
+      <stop offset="100%" stop-color="#ea580c" stop-opacity="0"/>
+    </radialGradient>
+  </defs>
+  <!-- Rounded hexagon / tech shield background -->
+  <path d="M32 4 L56 18 L56 46 L32 60 L8 46 L8 18 Z" fill="#f0fdfa"/>
+  <path d="M32 8 L52 20 L52 44 L32 56 L12 44 L12 20 Z" fill="url(#techGrad)" opacity="0.1"/>
+  <!-- Modern cylindrical moxa representation -->
+  <rect x="24" y="28" width="16" height="20" rx="4" fill="url(#techGrad)"/>
+  <rect x="24" y="26" width="16" height="6" rx="3" fill="#10b981"/>
+  <!-- Glowing ember at the top (Acupoint/Heat) -->
+  <circle cx="32" cy="22" r="14" fill="url(#emberGlow)"/>
+  <circle cx="32" cy="22" r="6" fill="#fef08a"/>
+  <!-- Tech circuit line connecting to the point (acupuncture meridian) -->
+  <path d="M32 48 L32 54" stroke="#059669" stroke-width="2" stroke-linecap="round"/>
+  <path d="M20 38 L16 38" stroke="#059669" stroke-width="2" stroke-linecap="round"/>
+  <path d="M44 38 L48 38" stroke="#059669" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 28 - 0
openspec/aijiuyiBackend/admin-ui/public/favicon-traditional.svg

@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <defs>
+    <linearGradient id="yinGrad" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0%" stop-color="#14b8a6"/>
+      <stop offset="100%" stop-color="#047857"/>
+    </linearGradient>
+    <linearGradient id="fireGrad" x1="0" y1="1" x2="0" y2="0">
+      <stop offset="0%" stop-color="#ef4444"/>
+      <stop offset="50%" stop-color="#f97316"/>
+      <stop offset="100%" stop-color="#fcd34d"/>
+    </linearGradient>
+  </defs>
+  <!-- Outer ring -->
+  <circle cx="32" cy="32" r="30" fill="none" stroke="url(#yinGrad)" stroke-width="4"/>
+  <circle cx="32" cy="32" r="26" fill="#f8fafc"/>
+  
+  <!-- Traditional Wave / Smoke pattern -->
+  <path d="M6 32 C16 20 20 44 32 32 C44 20 48 44 58 32 C58 46 46 58 32 58 C18 58 6 46 6 32 Z" fill="url(#yinGrad)" opacity="0.15"/>
+  
+  <!-- Abstract Acupoint & Moxa Fire -->
+  <circle cx="32" cy="40" r="8" fill="url(#yinGrad)"/>
+  <circle cx="32" cy="40" r="3" fill="#ffffff"/>
+  
+  <path d="M32 12 C36 18 42 24 38 32 C35 38 29 38 26 32 C22 24 28 18 32 12 Z" fill="url(#fireGrad)"/>
+  
+  <!-- Abstract Meridian Lines -->
+  <path d="M32 48 L32 56 M24 40 L16 40 M40 40 L48 40" stroke="url(#yinGrad)" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
+</svg>

+ 30 - 0
openspec/aijiuyiBackend/admin-ui/public/favicon.svg

@@ -0,0 +1,30 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <defs>
+    <linearGradient id="cone" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0%" stop-color="#2a9d8f"/>
+      <stop offset="100%" stop-color="#1a6b5f"/>
+    </linearGradient>
+    <linearGradient id="flame" x1="0.5" y1="1" x2="0.5" y2="0">
+      <stop offset="0%" stop-color="#e76f51"/>
+      <stop offset="50%" stop-color="#f4a261"/>
+      <stop offset="100%" stop-color="#ffd166"/>
+    </linearGradient>
+    <linearGradient id="glow" x1="0.5" y1="1" x2="0.5" y2="0">
+      <stop offset="0%" stop-color="#e76f51" stop-opacity="0.4"/>
+      <stop offset="100%" stop-color="#ffd166" stop-opacity="0"/>
+    </linearGradient>
+  </defs>
+  <!-- Circular background -->
+  <circle cx="32" cy="32" r="30" fill="#f0f7f5"/>
+  <!-- Moxa cone body -->
+  <path d="M32 52 L18 28 Q32 24 46 28 Z" fill="url(#cone)" stroke="#1a6b5f" stroke-width="0.5"/>
+  <!-- Cone texture line -->
+  <path d="M28 38 Q32 36 36 38" fill="none" stroke="#228176" stroke-width="1" opacity="0.5"/>
+  <path d="M26 42 Q32 40 38 42" fill="none" stroke="#228176" stroke-width="1" opacity="0.4"/>
+  <!-- Glow around flame -->
+  <ellipse cx="32" cy="22" rx="8" ry="10" fill="url(#glow)"/>
+  <!-- Main flame -->
+  <path d="M32 10 C36 16 40 20 38 25 C36 28 34 28 32 28 C30 28 28 28 26 25 C24 20 28 16 32 10Z" fill="url(#flame)"/>
+  <!-- Inner flame highlight -->
+  <path d="M32 16 C34 20 36 22 35 24 C34 26 33 26 32 26 C31 26 30 26 29 24 C28 22 30 20 32 16Z" fill="#ffd166" opacity="0.7"/>
+</svg>

BIN
openspec/aijiuyiBackend/admin-ui/public/healthy-back.png


+ 6 - 0
openspec/aijiuyiBackend/admin-ui/src/App.vue

@@ -0,0 +1,6 @@
+<template>
+  <router-view />
+</template>
+
+<script setup>
+</script>

+ 53 - 0
openspec/aijiuyiBackend/admin-ui/src/api/http.js

@@ -0,0 +1,53 @@
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+import router from '../router'
+
+const http = axios.create({
+  baseURL: '/api',
+  timeout: 60000,
+})
+
+http.interceptors.request.use((config) => {
+  const token = localStorage.getItem('admin_token')
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`
+  }
+  return config
+})
+
+http.interceptors.response.use(
+  (res) => {
+    const body = res.data
+    if (body && typeof body.code === 'number') {
+      if (body.code !== 0) {
+        ElMessage.error(body.message || '请求失败')
+        return Promise.reject(new Error(body.message || '业务错误'))
+      }
+      res.data = body.data
+      return res
+    }
+    return res
+  },
+  (err) => {
+    const status = err.response?.status
+    const data = err.response?.data
+    const apiMsg = data && typeof data.message === 'string' ? data.message : null
+    if (status === 401 || status === 403) {
+      const onLogin = router.currentRoute.value.name === 'login'
+      if (!onLogin) {
+        localStorage.removeItem('admin_token')
+        localStorage.removeItem('admin_username')
+        localStorage.removeItem('admin_roles')
+        ElMessage.error(apiMsg || '登录已失效,请重新登录')
+        router.push({ name: 'login' })
+      } else if (status === 401) {
+        ElMessage.error(apiMsg || '用户名或密码错误')
+      }
+    } else {
+      ElMessage.error(apiMsg || err.message || '网络错误')
+    }
+    return Promise.reject(err)
+  }
+)
+
+export default http

+ 181 - 0
openspec/aijiuyiBackend/admin-ui/src/config/resources.js

@@ -0,0 +1,181 @@
+export const RESOURCE_MAP = {
+  acupoints: {
+    title: '穴位管理',
+    apiPath: '/acupoints',
+    searchApiPath: '/acupoints/search',
+    exportApiPath: '/acupoints/export',
+    batchDeleteApiPath: '/acupoints/batch-delete',
+    columns: [
+      { prop: 'id', label: 'ID', width: 80 },
+      { prop: 'code', label: '编码', width: 120 },
+      { prop: 'name', label: '名称', width: 100 },
+      { prop: 'meridian', label: '所属经络', width: 140 },
+      { prop: 'efficacy', label: '功效', minWidth: 160 },
+      { prop: 'side', label: '侧别', width: 80 },
+      { prop: 'status', label: '状态', width: 80 },
+      { prop: 'createTime', label: '创建时间', width: 170 },
+    ],
+    formFields: [
+      { prop: 'code', label: '穴位编码', type: 'input', required: true },
+      { prop: 'name', label: '穴位名称', type: 'input', required: true },
+      { prop: 'meridian', label: '所属经络', type: 'select', options: ['督脉', '足太阳膀胱经', '手太阳小肠经', '其他'] },
+      { prop: 'efficacy', label: '功效(逗号分隔)', type: 'input' },
+      { prop: 'positioning', label: '定位方式', type: 'select', options: ['relative', 'absolute'] },
+      { prop: 'offsetCun', label: '纵向偏移(寸)', type: 'input' },
+      { prop: 'cunType', label: '指寸类型', type: 'select', options: ['cun1', 'cun1_5', 'cun3'] },
+      { prop: 'side', label: '侧别', type: 'select', options: ['center', 'both', 'left', 'right'] },
+      { prop: 'lateralOffset', label: '横向偏移(寸)', type: 'input' },
+      { prop: 'targetGender', label: '适用性别', type: 'select', options: ['all', 'male', 'female'] },
+      { prop: 'status', label: '状态', type: 'select', options: ['active', 'pending'] },
+      { prop: 'source', label: '来源', type: 'select', options: ['preset', 'expert'] },
+    ],
+    searchFields: [
+      { prop: 'name', label: '穴位名称', type: 'input' },
+      { prop: 'meridian', label: '所属经络', type: 'select', options: ['', '督脉', '足太阳膀胱经', '手太阳小肠经'] },
+      { prop: 'positioning', label: '定位方式', type: 'select', options: ['', 'relative', 'absolute'] },
+      { prop: 'status', label: '状态', type: 'select', options: ['', 'active', 'pending'] },
+    ],
+  },
+  'moxa-techniques': {
+    title: '艾灸手法',
+    apiPath: '/moxa-techniques',
+    searchApiPath: '/moxa-techniques/search',
+    columns: [
+      { prop: 'id', label: 'ID', width: 80 },
+      { prop: 'code', label: '编码', minWidth: 100 },
+      { prop: 'name', label: '名称', minWidth: 120 },
+      { prop: 'movementPattern', label: '运动模式', minWidth: 120 },
+      { prop: 'tempMin', label: '最低温度', width: 100 },
+      { prop: 'tempMax', label: '最高温度', width: 100 },
+      { prop: 'status', label: '状态', width: 80 },
+    ],
+    formFields: [
+      { prop: 'code', label: '编码', type: 'input', required: true },
+      { prop: 'name', label: '名称', type: 'input', required: true },
+      { prop: 'isDefault', label: '默认手法', type: 'select', options: [{ label: '否', value: 0 }, { label: '是', value: 1 }] },
+      { prop: 'description', label: '描述', type: 'textarea' },
+      { prop: 'movementPattern', label: '运动模式', type: 'select', options: ['fixed', 'vertical', 'circular', 'point', 'oneclick'], required: true },
+      { prop: 'tempMin', label: '最低温度(°C)', type: 'input' },
+      { prop: 'tempMax', label: '最高温度(°C)', type: 'input' },
+      { prop: 'durationMin', label: '最短时间(秒)', type: 'number' },
+      { prop: 'durationMax', label: '最长时间(秒)', type: 'number' },
+      { prop: 'movementAmplitude', label: '运动幅度(mm)', type: 'input' },
+      { prop: 'movementSpeed', label: '运动速度(mm/s)', type: 'input' },
+      { prop: 'status', label: '状态', type: 'select', options: ['active', 'disabled'] },
+      { prop: 'remark', label: '备注', type: 'textarea' },
+    ],
+    searchFields: [
+      { prop: 'name', label: '名称', type: 'input' },
+      { prop: 'movementPattern', label: '运动模式', type: 'select', options: ['', 'fixed', 'vertical', 'circular', 'point', 'oneclick'] },
+      { prop: 'status', label: '状态', type: 'select', options: ['', 'active', 'disabled'] },
+    ],
+  },
+  users: {
+    title: '用户管理',
+    apiPath: '/users',
+    searchApiPath: '/users/search',
+    exportApiPath: '/users/export',
+    batchDeleteApiPath: '/users/batch-delete',
+    columns: [
+      { prop: 'id', label: 'ID', width: 80 },
+      { prop: 'name', label: '姓名', minWidth: 100 },
+      { prop: 'gender', label: '性别', width: 70 },
+      { prop: 'age', label: '年龄', width: 70 },
+      { prop: 'phone', label: '手机号', width: 130 },
+      { prop: 'shoulderWidth', label: '肩宽(cm)', width: 90 },
+      { prop: 'bodyLength', label: '身长(cm)', width: 90 },
+      { prop: 'acupointTableStatus', label: '穴位表', minWidth: 100 },
+      { prop: 'createTime', label: '注册时间', width: 170 },
+    ],
+    formFields: [
+      { prop: 'name', label: '姓名', type: 'input', required: true },
+      { prop: 'gender', label: '性别', type: 'select', options: ['male', 'female'], required: true },
+      { prop: 'age', label: '年龄', type: 'number', required: true },
+      { prop: 'phone', label: '手机号', type: 'input', required: true },
+      { prop: 'shoulderWidth', label: '肩宽(cm)', type: 'input', required: true },
+      { prop: 'bodyLength', label: '身长(cm)', type: 'input', required: true },
+      { prop: 'fingerCun1', label: '一寸(cm)', type: 'input' },
+      { prop: 'fingerCun1_5', label: '1.5寸(cm)', type: 'input' },
+      { prop: 'fingerCun3', label: '三寸(cm)', type: 'input' },
+    ],
+    searchFields: [
+      { prop: 'keyword', label: '姓名/手机号', type: 'input' },
+      { prop: 'gender', label: '性别', type: 'select', options: ['', 'male', 'female'] },
+      { prop: 'acupointTableStatus', label: '穴位表状态', type: 'select', options: ['', 'GENERATED', 'NOT_GENERATED'] },
+      { prop: 'deviceCode', label: '设备编号', type: 'input' },
+    ],
+    extraActions: ['generateAcupoint', 'manageDevices'],
+  },
+  devices: {
+    title: '设备管理',
+    apiPath: '/devices',
+    searchApiPath: '/devices/search',
+    exportApiPath: '/devices/export',
+    batchDeleteApiPath: '/devices/batch-delete',
+    columns: [
+      { prop: 'id', label: 'ID', width: 80 },
+      { prop: 'deviceCode', label: '设备编号', minWidth: 120 },
+      { prop: 'deviceName', label: '设备名称', minWidth: 140 },
+      { prop: 'model', label: '型号', width: 100 },
+      { prop: 'serialNumber', label: '序列号', minWidth: 140 },
+      { prop: 'firmwareVersion', label: '固件版本', width: 100 },
+      { prop: 'status', label: '状态', width: 80 },
+    ],
+    formFields: [
+      { prop: 'deviceCode', label: '设备编号', type: 'input', required: true },
+      { prop: 'deviceName', label: '设备名称', type: 'input', required: true },
+      { prop: 'model', label: '设备型号', type: 'select', options: ['AJY-2026', 'AJY-2025'], required: true },
+      { prop: 'serialNumber', label: '序列号', type: 'input', required: true },
+      { prop: 'firmwareVersion', label: '固件版本', type: 'input' },
+      { prop: 'status', label: '状态', type: 'select', options: ['online', 'offline'] },
+      { prop: 'remark', label: '备注', type: 'textarea' },
+    ],
+    searchFields: [
+      { prop: 'deviceCode', label: '设备编号', type: 'input' },
+      { prop: 'deviceName', label: '设备名称', type: 'input' },
+      { prop: 'status', label: '在线状态', type: 'select', options: ['', 'online', 'offline'] },
+      { prop: 'userName', label: '绑定用户', type: 'input' },
+    ],
+    extraActions: ['manageUsers'],
+  },
+  schemes: {
+    title: '方案管理',
+    apiPath: '/schemes',
+    searchApiPath: '/schemes/search',
+    batchDeleteApiPath: '/schemes/batch-delete',
+    columns: [
+      { prop: 'id', label: 'ID', width: 80 },
+      { prop: 'schemeName', label: '方案名称', minWidth: 140 },
+      { prop: 'mode', label: '模式', width: 120 },
+      { prop: 'efficacyType', label: '功效类型', width: 90 },
+      { prop: 'creatorName', label: '创作人', width: 100 },
+      { prop: 'status', label: '状态', width: 80 },
+      { prop: 'createTime', label: '创建时间', width: 170 },
+    ],
+    formFields: [
+      { prop: 'schemeName', label: '方案名称', type: 'input', required: true },
+      { prop: 'mode', label: '模式', type: 'select', options: ['ONE_CLICK', 'PROFESSIONAL', 'CUSTOM', 'EXPERT'], required: true },
+      { prop: 'efficacyType', label: '功效类型', type: 'select', options: ['', '驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳'] },
+      { prop: 'creatorType', label: '创作人类型', type: 'select', options: ['admin', 'user', 'expert'], required: true },
+      { prop: 'creatorName', label: '创作人名称', type: 'input' },
+      { prop: 'symptoms', label: '适用症状(逗号分隔)', type: 'input' },
+      { prop: 'targetGender', label: '适用性别', type: 'select', options: ['all', 'male', 'female'] },
+      { prop: 'ageMin', label: '最小年龄', type: 'number' },
+      { prop: 'ageMax', label: '最大年龄', type: 'number' },
+      { prop: 'description', label: '方案描述', type: 'textarea' },
+      { prop: 'status', label: '状态', type: 'select', options: ['draft', 'published', 'archived'] },
+    ],
+    searchFields: [
+      { prop: 'schemeName', label: '方案名称', type: 'input' },
+      { prop: 'mode', label: '模式', type: 'select', options: ['', 'ONE_CLICK', 'PROFESSIONAL', 'CUSTOM', 'EXPERT'] },
+      { prop: 'efficacyType', label: '功效类型', type: 'select', options: ['', '驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳'] },
+      { prop: 'status', label: '状态', type: 'select', options: ['', 'draft', 'published', 'archived'] },
+      { prop: 'creatorName', label: '创作人', type: 'input' },
+    ],
+    extraActions: ['viewDetail', 'publish', 'archive'],
+  },
+}
+
+export function getResourceConfig(key) {
+  return RESOURCE_MAP[key]
+}

+ 232 - 0
openspec/aijiuyiBackend/admin-ui/src/layouts/AdminLayout.vue

@@ -0,0 +1,232 @@
+<template>
+  <el-container class="layout">
+    <el-aside width="240px" class="aside">
+      <div class="brand">
+        <img src="/favicon-leaf.svg" alt="logo" class="brand-logo" />
+        艾灸管理后台
+      </div>
+      <el-menu
+        class="sidebar-menu"
+        :default-active="active"
+        router
+        unique-opened
+      >
+        <el-menu-item index="/home">首页</el-menu-item>
+        <el-sub-menu index="dict">
+          <template #title>算法参数</template>
+          <el-menu-item v-for="m in menuDict" :key="m.path" :index="m.path">{{ m.title }}</el-menu-item>
+        </el-sub-menu>
+        <el-sub-menu index="user">
+          <template #title>用户与设备</template>
+          <el-menu-item v-for="m in menuUser" :key="m.path" :index="m.path">{{ m.title }}</el-menu-item>
+          <el-menu-item v-for="m in menuBiz" :key="m.path" :index="m.path">{{ m.title }}</el-menu-item>
+        </el-sub-menu>
+        <el-menu-item index="/admin-users">后台账号</el-menu-item>
+        <el-menu-item index="/operation-logs">操作日志</el-menu-item>
+        <el-menu-item index="/localization">穴位定位预览</el-menu-item>
+        <el-menu-item index="/simulation">方案模拟测试</el-menu-item>
+      </el-menu>
+    </el-aside>
+    <el-container class="content-wrapper">
+      <el-header class="header">
+        <span class="title">{{ pageTitle }}</span>
+        <div class="right">
+          <div class="user-info">
+            <div class="user-avatar">{{ username.charAt(0).toUpperCase() }}</div>
+            <span class="user">{{ username }}</span>
+          </div>
+          <el-button link type="primary" @click="logout" style="margin-left: 12px;">退出登录</el-button>
+        </div>
+      </el-header>
+      <el-main class="main">
+        <router-view />
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { getResourceConfig } from '../config/resources'
+
+const route = useRoute()
+const router = useRouter()
+
+const active = computed(() => route.path)
+const username = computed(() => localStorage.getItem('admin_username') || 'A')
+
+const pageTitle = computed(() => {
+  if (route.name === 'crud') {
+    const c = getResourceConfig(route.params.resource)
+    return c?.title || '资源管理'
+  }
+  return route.meta?.title || '后台管理'
+})
+
+const menuDict = [
+  { path: '/crud/acupoints', title: '穴位管理' },
+  { path: '/crud/moxa-techniques', title: '艾灸手法' },
+  { path: '/crud/schemes', title: '方案管理' },
+]
+
+const menuBiz = [
+  { path: '/crud/devices', title: '设备管理' },
+]
+
+const menuUser = [
+  { path: '/crud/users', title: '用户管理' },
+]
+
+function logout() {
+  localStorage.removeItem('admin_token')
+  localStorage.removeItem('admin_username')
+  localStorage.removeItem('admin_roles')
+  router.push({ name: 'login' })
+}
+</script>
+
+<style scoped>
+.layout {
+  height: 100%;
+}
+.aside {
+  background: #ffffff;
+  border-right: 1px solid #f1f5f9;
+  box-shadow: 2px 0 8px rgba(0, 0, 0, 0.02);
+  z-index: 10;
+  display: flex;
+  flex-direction: column;
+}
+.brand {
+  height: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  color: #10b981;
+  font-size: 18px;
+  font-weight: 700;
+  letter-spacing: 0.05em;
+  border-bottom: 1px solid #f1f5f9;
+}
+.brand-logo {
+  width: 28px;
+  height: 28px;
+  flex-shrink: 0;
+}
+.sidebar-menu {
+  border-right: none;
+  flex: 1;
+  padding: 12px 0;
+  overflow-x: hidden;
+}
+.content-wrapper {
+  background: #f4f7f6;
+  display: flex;
+  flex-direction: column;
+}
+.header {
+  height: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #ffffff;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
+  z-index: 9;
+  padding: 0 24px;
+}
+.title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #334155;
+}
+.right {
+  display: flex;
+  align-items: center;
+}
+.user-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.user-avatar {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  background: #ecfdf5;
+  color: #10b981;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 600;
+  font-size: 14px;
+}
+.user {
+  color: #475569;
+  font-size: 14px;
+  font-weight: 500;
+}
+.main {
+  background: transparent;
+  padding: 24px;
+}
+
+/* 一级菜单 */
+.sidebar-menu :deep(.el-menu-item) {
+  height: 44px;
+  line-height: 44px;
+  margin: 4px 12px;
+  border-radius: 8px;
+  color: #475569;
+  font-weight: 500;
+}
+
+.sidebar-menu :deep(.el-menu-item:hover) {
+  background-color: #f8fafc;
+  color: #10b981;
+}
+
+.sidebar-menu :deep(.el-menu-item.is-active) {
+  background-color: #ecfdf5;
+  color: #10b981;
+  font-weight: 600;
+}
+
+/* 一级分组标题 */
+.sidebar-menu :deep(.el-sub-menu__title) {
+  height: 44px;
+  line-height: 44px;
+  margin: 4px 12px;
+  border-radius: 8px;
+  color: #475569;
+  font-weight: 500;
+}
+.sidebar-menu :deep(.el-sub-menu__title:hover) {
+  background-color: #f8fafc;
+  color: #10b981;
+}
+
+/* 二级菜单 */
+.sidebar-menu :deep(.el-menu--inline) {
+  background-color: transparent;
+}
+.sidebar-menu :deep(.el-menu--inline .el-menu-item) {
+  height: 40px;
+  line-height: 40px;
+  margin: 4px 12px 4px 32px;
+  border-radius: 8px;
+  color: #64748b;
+  font-size: 13px;
+  font-weight: 400;
+}
+.sidebar-menu :deep(.el-menu--inline .el-menu-item:hover) {
+  background-color: #f8fafc;
+  color: #10b981;
+}
+.sidebar-menu :deep(.el-menu--inline .el-menu-item.is-active) {
+  background-color: #ecfdf5;
+  color: #10b981;
+  font-weight: 500;
+}
+</style>

+ 9 - 0
openspec/aijiuyiBackend/admin-ui/src/main.js

@@ -0,0 +1,9 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+import App from './App.vue'
+import router from './router'
+import './style.css'
+
+createApp(App).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')

+ 82 - 0
openspec/aijiuyiBackend/admin-ui/src/router/index.js

@@ -0,0 +1,82 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import AdminLayout from '../layouts/AdminLayout.vue'
+
+const routes = [
+  {
+    path: '/login',
+    name: 'login',
+    component: () => import('../views/Login.vue'),
+    meta: { public: true },
+  },
+  {
+    path: '/',
+    component: AdminLayout,
+    redirect: '/home',
+    children: [
+      { path: 'home', name: 'home', component: () => import('../views/Home.vue'), meta: { title: '首页' } },
+      {
+        path: 'crud/:resource',
+        name: 'crud',
+        component: () => import('../views/resource/CrudView.vue'),
+        meta: { title: '资源' },
+      },
+      {
+        path: 'admin-users',
+        name: 'admin-users',
+        component: () => import('../views/AdminUsersView.vue'),
+        meta: { title: '后台账号' },
+      },
+      {
+        path: 'operation-logs',
+        name: 'operation-logs',
+        component: () => import('../views/OperationLogsView.vue'),
+        meta: { title: '操作日志' },
+      },
+      {
+        path: 'localization',
+        name: 'localization',
+        component: () => import('../views/LocalizationPreviewView.vue'),
+        meta: { title: '穴位定位预览' },
+      },
+      {
+        path: 'schemes/:schemeId/detail',
+        name: 'scheme-detail',
+        component: () => import('../views/SchemeDetailView.vue'),
+        meta: { title: '方案详情' },
+      },
+      {
+        path: 'users/:userId/devices',
+        name: 'user-devices',
+        component: () => import('../views/UserDevicesView.vue'),
+        meta: { title: '用户设备管理' },
+      },
+      {
+        path: 'devices/:deviceId/users',
+        name: 'device-users',
+        component: () => import('../views/DeviceUsersView.vue'),
+        meta: { title: '设备用户管理' },
+      },
+      {
+        path: 'simulation',
+        name: 'simulation',
+        component: () => import('../views/SchemeSimulationView.vue'),
+        meta: { title: '方案模拟测试' },
+      },
+    ],
+  },
+]
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes,
+})
+
+router.beforeEach((to) => {
+  if (to.meta.public) return true
+  if (!localStorage.getItem('admin_token')) {
+    return { name: 'login', query: { redirect: to.fullPath } }
+  }
+  return true
+})
+
+export default router

+ 43 - 0
openspec/aijiuyiBackend/admin-ui/src/style.css

@@ -0,0 +1,43 @@
+* {
+  box-sizing: border-box;
+}
+
+:root {
+  /* Element Plus Theme Variables - Nature Green */
+  --el-color-primary: #10b981;
+  --el-color-primary-light-3: #4ade80;
+  --el-color-primary-light-5: #86efac;
+  --el-color-primary-light-7: #bbf7d0;
+  --el-color-primary-light-8: #dcfce7;
+  --el-color-primary-light-9: #f0fdf4;
+  --el-color-primary-dark-2: #059669;
+
+  /* Global Border Radius */
+  --el-border-radius-base: 8px;
+  --el-border-radius-small: 6px;
+  --el-border-radius-round: 20px;
+  --el-border-radius-circle: 100%;
+  
+  /* Global Box Shadow */
+  --el-box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+  --el-box-shadow-light: 0 2px 8px rgba(0, 0, 0, 0.03);
+  
+  /* Text Colors */
+  --el-text-color-primary: #334155;
+  --el-text-color-regular: #475569;
+  --el-text-color-secondary: #64748b;
+  
+  /* Card */
+  --el-card-border-radius: 12px;
+  --el-card-padding: 24px;
+}
+
+html,
+body,
+#app {
+  margin: 0;
+  height: 100%;
+  font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+  background-color: #f4f7f6;
+  color: var(--el-text-color-primary);
+}

+ 258 - 0
openspec/aijiuyiBackend/admin-ui/src/views/AdminUsersView.vue

@@ -0,0 +1,258 @@
+<template>
+  <el-card shadow="never" class="modern-card">
+    <div class="toolbar">
+      <el-button v-if="canManage" type="primary" @click="openCreate" class="btn-create">新建管理员</el-button>
+    </div>
+    <el-table :data="rows" class="modern-table" v-loading="loading" style="width: 100%">
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="username" label="用户名" width="140" />
+      <el-table-column prop="displayName" label="显示名" min-width="120" />
+      <el-table-column prop="role" label="角色" width="120" />
+      <el-table-column prop="status" label="状态" width="100" />
+      <el-table-column prop="createTime" label="创建时间" width="170" />
+      <el-table-column label="操作" width="220" fixed="right">
+        <template #default="{ row }">
+          <el-button v-if="canManage" link type="primary" @click="openEdit(row)">编辑</el-button>
+          <el-button v-if="isSuper" link type="warning" @click="openPwd(row)">改密</el-button>
+          <el-button v-if="isSuper" link type="danger" @click="remove(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-pagination
+      class="pager"
+      background
+      layout="total, prev, pager, next"
+      :total="total"
+      v-model:current-page="page.current"
+      :page-size="page.size"
+      @current-change="fetchList"
+    />
+
+    <el-dialog v-model="createVisible" title="新建管理员" width="480px" destroy-on-close>
+      <el-form ref="createRef" :model="createForm" :rules="createRules" label-width="88px">
+        <el-form-item label="用户名" prop="username">
+          <el-input v-model="createForm.username" />
+        </el-form-item>
+        <el-form-item label="密码" prop="password">
+          <el-input v-model="createForm.password" type="password" show-password />
+        </el-form-item>
+        <el-form-item label="显示名" prop="displayName">
+          <el-input v-model="createForm.displayName" />
+        </el-form-item>
+        <el-form-item label="角色" prop="role">
+          <el-select v-model="createForm.role" style="width: 100%">
+            <el-option label="管理员" value="ADMIN" />
+            <el-option label="超级管理员" value="SUPER_ADMIN" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="submitCreate">创建</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="editVisible" title="编辑管理员" width="480px" destroy-on-close>
+      <el-form ref="editRef" :model="editForm" label-width="88px">
+        <el-form-item label="用户名">
+          <el-input v-model="editForm.username" disabled />
+        </el-form-item>
+        <el-form-item label="显示名">
+          <el-input v-model="editForm.displayName" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-input v-model="editForm.status" placeholder="ACTIVE" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="editVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="submitEdit">保存</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="pwdVisible" title="重置密码(仅超级管理员)" width="440px" destroy-on-close>
+      <el-form ref="pwdRef" :model="pwdForm" :rules="pwdRules" label-width="100px">
+        <el-form-item label="新密码" prop="newPassword">
+          <el-input v-model="pwdForm.newPassword" type="password" show-password />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="pwdVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="submitPwd">保存</el-button>
+      </template>
+    </el-dialog>
+  </el-card>
+</template>
+
+<script setup>
+import { computed, reactive, ref } from 'vue'
+import { ElMessageBox } from 'element-plus'
+import http from '../api/http'
+
+const role = computed(() => localStorage.getItem('admin_roles') || '')
+const isSuper = computed(() => role.value === 'SUPER_ADMIN')
+const canManage = computed(() => role.value === 'SUPER_ADMIN' || role.value === 'ADMIN')
+
+const rows = ref([])
+const total = ref(0)
+const loading = ref(false)
+const page = reactive({ current: 1, size: 10 })
+
+const createVisible = ref(false)
+const createRef = ref()
+const createForm = reactive({ username: '', password: '', displayName: '', role: 'ADMIN' })
+const createRules = {
+  username: [{ required: true, message: '必填', trigger: 'blur' }],
+  password: [{ required: true, message: '必填', trigger: 'blur' }, { min: 8, message: '至少8位', trigger: 'blur' }],
+  displayName: [{ required: true, message: '必填', trigger: 'blur' }],
+  role: [{ required: true, message: '必填', trigger: 'blur' }],
+}
+
+const editVisible = ref(false)
+const editRef = ref()
+const editForm = reactive({ id: null, username: '', displayName: '', status: '' })
+
+const pwdVisible = ref(false)
+const pwdRef = ref()
+const pwdForm = reactive({ userId: null, newPassword: '' })
+const pwdRules = {
+  newPassword: [{ required: true, message: '必填', trigger: 'blur' }],
+}
+
+const saving = ref(false)
+
+async function fetchList() {
+  loading.value = true
+  try {
+    const res = await http.get('/admin-users', {
+      params: { current: page.current, size: page.size },
+    })
+    const pr = res.data || {}
+    rows.value = pr.records || []
+    total.value = pr.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+fetchList()
+
+function openCreate() {
+  createForm.username = ''
+  createForm.password = ''
+  createForm.displayName = ''
+  createForm.role = 'ADMIN'
+  createVisible.value = true
+}
+
+async function submitCreate() {
+  await createRef.value?.validate()
+  saving.value = true
+  try {
+    await http.post('/admin-users/create', { ...createForm })
+    createVisible.value = false
+    await fetchList()
+  } finally {
+    saving.value = false
+  }
+}
+
+async function openEdit(row) {
+  const res = await http.get(`/admin-users/${row.id}`)
+  const u = res.data
+  editForm.id = u.id
+  editForm.username = u.username
+  editForm.displayName = u.displayName || ''
+  editForm.status = u.status || 'ACTIVE'
+  editVisible.value = true
+}
+
+async function submitEdit() {
+  saving.value = true
+  try {
+    await http.put(`/admin-users/${editForm.id}`, {
+      id: editForm.id,
+      username: editForm.username,
+      displayName: editForm.displayName,
+      status: editForm.status,
+    })
+    editVisible.value = false
+    await fetchList()
+  } finally {
+    saving.value = false
+  }
+}
+
+function openPwd(row) {
+  pwdForm.userId = row.id
+  pwdForm.newPassword = ''
+  pwdVisible.value = true
+}
+
+async function submitPwd() {
+  await pwdRef.value?.validate()
+  saving.value = true
+  try {
+    await http.put(`/admin-users/${pwdForm.userId}/password`, {
+      newPassword: pwdForm.newPassword,
+    })
+    pwdVisible.value = false
+  } finally {
+    saving.value = false
+  }
+}
+
+async function remove(row) {
+  await ElMessageBox.confirm(`删除管理员 ${row.username}?`, '确认', { type: 'warning' })
+  await http.delete(`/admin-users/${row.id}`)
+  await fetchList()
+}
+</script>
+
+<style scoped>
+.modern-card {
+  min-height: 400px;
+  border: none;
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
+  padding: 4px;
+}
+.toolbar {
+  margin-bottom: 20px;
+  padding: 0 4px;
+}
+.btn-create {
+  border-radius: 6px;
+  padding: 8px 20px;
+  font-weight: 500;
+  box-shadow: 0 2px 6px rgba(16, 185, 129, 0.2);
+}
+.pager {
+  margin-top: 24px;
+  justify-content: flex-end;
+  padding-right: 4px;
+}
+
+/* Modern Table Overrides */
+.modern-table {
+  border-radius: 8px;
+  overflow: hidden;
+}
+.modern-table :deep(.el-table__inner-wrapper::before) {
+  display: none;
+}
+.modern-table :deep(th.el-table__cell) {
+  background-color: #f8fafc;
+  color: #475569;
+  font-weight: 600;
+  border-bottom: 1px solid #e2e8f0;
+  padding: 12px 0;
+}
+.modern-table :deep(td.el-table__cell) {
+  border-bottom: 1px solid #f1f5f9;
+  padding: 12px 0;
+}
+.modern-table :deep(.el-table__row:hover > td.el-table__cell) {
+  background-color: #f8fafc;
+}
+</style>

+ 125 - 0
openspec/aijiuyiBackend/admin-ui/src/views/DeviceUsersView.vue

@@ -0,0 +1,125 @@
+<template>
+  <div>
+    <el-page-header @back="goBack" style="margin-bottom: 20px">
+      <template #content><span class="page-title">设备用户管理 - {{ deviceName }}</span></template>
+    </el-page-header>
+
+    <el-card shadow="never" class="section-card">
+      <div class="toolbar">
+        <el-button type="primary" @click="showBindDialog = true">添加用户</el-button>
+      </div>
+
+      <el-table :data="users" class="modern-table" v-loading="loading" style="width: 100%">
+        <el-table-column label="用户ID" width="100">
+          <template #default="{ row }">{{ row.user?.id }}</template>
+        </el-table-column>
+        <el-table-column label="用户姓名" min-width="100">
+          <template #default="{ row }">{{ row.user?.name }}</template>
+        </el-table-column>
+        <el-table-column label="手机号" width="130">
+          <template #default="{ row }">{{ row.user?.phone }}</template>
+        </el-table-column>
+        <el-table-column label="绑定时间" width="170">
+          <template #default="{ row }">{{ row.binding?.boundAt }}</template>
+        </el-table-column>
+        <el-table-column label="是否主用户" width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.binding?.isPrimary === 1 ? 'success' : 'info'" size="small">
+              {{ row.binding?.isPrimary === 1 ? '是' : '否' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="setPrimary(row)">设为主用户</el-button>
+            <el-button link type="danger" @click="unbind(row)">解绑</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog v-model="showBindDialog" title="添加用户" width="500px">
+      <el-form label-width="80px">
+        <el-form-item label="选择用户">
+          <el-select v-model="bindUserId" filterable placeholder="搜索用户" style="width: 100%">
+            <el-option v-for="u in allUsers" :key="u.id" :label="`${u.name} - ${u.phone}`" :value="u.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showBindDialog = false">取消</el-button>
+        <el-button type="primary" @click="bindUser">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import http from '../api/http'
+
+const route = useRoute()
+const router = useRouter()
+const deviceId = route.params.deviceId
+const deviceName = ref('')
+const users = ref([])
+const loading = ref(false)
+const showBindDialog = ref(false)
+const bindUserId = ref(null)
+const allUsers = ref([])
+
+async function fetchUsers() {
+  loading.value = true
+  try {
+    const res = await http.get(`/devices/${deviceId}/users`)
+    users.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+async function fetchDevice() {
+  const res = await http.get(`/devices/${deviceId}`)
+  deviceName.value = res.data?.deviceName || deviceId
+}
+
+async function fetchAllUsers() {
+  const res = await http.get('/users', { params: { current: 1, size: 1000 } })
+  allUsers.value = res.data?.records || []
+}
+
+async function bindUser() {
+  if (!bindUserId.value) return
+  await http.post(`/devices/${deviceId}/users/${bindUserId.value}/bind`)
+  ElMessage.success('绑定成功')
+  showBindDialog.value = false
+  await fetchUsers()
+}
+
+async function unbind(row) {
+  await ElMessageBox.confirm('确定解绑此用户?', '确认', { type: 'warning' })
+  await http.delete(`/devices/${deviceId}/users/${row.binding.userId}/unbind`)
+  ElMessage.success('解绑成功')
+  await fetchUsers()
+}
+
+async function setPrimary(row) {
+  await http.put(`/devices/${deviceId}/users/${row.binding.userId}/primary`)
+  ElMessage.success('已设为主用户')
+  await fetchUsers()
+}
+
+function goBack() { router.push('/crud/devices') }
+
+onMounted(() => { fetchDevice(); fetchUsers(); fetchAllUsers() })
+</script>
+
+<style scoped>
+.page-title { font-size: 18px; font-weight: 600; color: #334155; }
+.section-card { border: none; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.02); }
+.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
+.modern-table { border-radius: 8px; overflow: hidden; }
+.modern-table :deep(th.el-table__cell) { background-color: #f8fafc; color: #475569; font-weight: 600; }
+</style>

+ 104 - 0
openspec/aijiuyiBackend/admin-ui/src/views/Home.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="home">
+    <el-card class="welcome-card" shadow="never">
+      <div class="welcome-header">
+        <div class="welcome-avatar">{{ username.charAt(0).toUpperCase() }}</div>
+        <div>
+          <h2>欢迎回来,{{ username }}</h2>
+          <p class="role-text">当前角色:<el-tag size="small" type="success" effect="light" round>{{ rolesText }}</el-tag></p>
+        </div>
+      </div>
+      <el-divider />
+      <div class="welcome-links">
+        <p class="muted">
+          <span class="icon">🔗</span> 后端 API 文档:
+          <el-link type="primary" href="/swagger-ui.html" target="_blank" class="font-medium">Swagger UI</el-link>
+          <span class="sub-muted">(需后端 8888,若单独开前端请直接访问后端地址)</span>
+        </p>
+        <p class="muted">
+          <span class="icon">💡</span> 提示:左侧菜单进入各模块的增删改查;穴位列表支持筛选「艾灸椅产品穴位」。
+        </p>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const username = computed(() => localStorage.getItem('admin_username') || '-')
+const rolesText = computed(() => {
+  try {
+    const r = JSON.parse(localStorage.getItem('admin_roles') || '[]')
+    return Array.isArray(r) && r.length ? r.join(', ') : '无'
+  } catch {
+    return '-'
+  }
+})
+</script>
+
+<style scoped>
+.home {
+  max-width: 960px;
+}
+.welcome-card {
+  border: none;
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
+  padding: 8px;
+}
+.welcome-header {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding: 8px 0;
+}
+.welcome-avatar {
+  width: 64px;
+  height: 64px;
+  border-radius: 16px;
+  background: linear-gradient(135deg, #34d399, #10b981);
+  color: white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28px;
+  font-weight: bold;
+  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
+}
+.welcome-header h2 {
+  margin: 0 0 8px 0;
+  font-size: 20px;
+  color: #0f172a;
+  font-weight: 600;
+}
+.role-text {
+  margin: 0;
+  color: #64748b;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.welcome-links p {
+  display: flex;
+  align-items: center;
+  margin: 16px 0;
+}
+.icon {
+  margin-right: 8px;
+  font-size: 16px;
+}
+.muted {
+  color: #475569;
+  font-size: 14px;
+}
+.sub-muted {
+  color: #94a3b8;
+  font-size: 13px;
+  margin-left: 4px;
+}
+.font-medium {
+  font-weight: 500;
+}
+</style>

+ 748 - 0
openspec/aijiuyiBackend/admin-ui/src/views/LocalizationPreviewView.vue

@@ -0,0 +1,748 @@
+<template>
+  <el-card shadow="never" class="modern-card">
+    <div class="preview-main">
+    <div class="preview-form-col">
+    <el-form :model="form" label-width="120px" class="form" :rules="formRules" ref="formRef">
+      <el-form-item label="穴位">
+        <el-select
+          v-model="selectedCodes"
+          multiple
+          filterable
+          collapse-tags
+          collapse-tags-tooltip
+          placeholder="下拉选择穴位(含编号),可多选"
+          style="width: 100%"
+          :loading="optionsLoading"
+        >
+          <el-option
+            v-for="opt in acupointOptions"
+            :key="opt.code"
+            :label="opt.label"
+            :value="opt.code"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="设备型号编码">
+        <el-input v-model="form.deviceModelCode" placeholder="可选,如 CHAIR-DEFAULT" />
+      </el-form-item>
+
+      <el-divider>用户体态</el-divider>
+
+      <el-form-item label="用户 ID">
+        <el-select
+          v-model="form.user.userId"
+          filterable
+          clearable
+          placeholder="可选,选择后自动填充"
+          style="width: 100%"
+          :loading="usersLoading"
+          @change="onUserSelect"
+        >
+          <el-option
+            v-for="u in userOptions"
+            :key="u.id"
+            :label="`${u.name || '未命名'} (ID: ${u.id})`"
+            :value="u.id"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="肩宽 cm" prop="shoulderWidthCm" required>
+        <el-input v-model="form.user.shoulderWidthCm" placeholder="必填" />
+      </el-form-item>
+      <el-form-item label="脊柱长度 cm" prop="spineLengthCm" required>
+        <el-input v-model="form.user.spineLengthCm" placeholder="必填,C7-S4 实测距离" />
+      </el-form-item>
+      <el-form-item label="指寸 cm">
+        <el-input v-model="form.user.fingerCunCm" placeholder="可选,中指中节长度" />
+      </el-form-item>
+      <el-form-item label="身高 cm" :required="!form.user.fingerCunCm">
+        <el-input v-model="form.user.heightCm" :placeholder="form.user.fingerCunCm ? '可选' : '指寸未填时必填'" />
+      </el-form-item>
+      <el-form-item label="体重 kg" :required="!form.user.fingerCunCm">
+        <el-input v-model="form.user.weightKg" :placeholder="form.user.fingerCunCm ? '可选' : '指寸未填时必填'" />
+      </el-form-item>
+      <el-form-item label="性别">
+        <el-select v-model="form.user.gender" placeholder="可选" clearable style="width: 100%">
+          <el-option label="男" value="male" />
+          <el-option label="女" value="female" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" :loading="loading" @click="preview">调用预览</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 指寸摘要 -->
+    <div v-if="result && result.finalFingerCunMm" class="cun-summary">
+      <div class="cun-summary-title">指寸计算摘要</div>
+      <div class="cun-row" v-if="result.userFingerCunMm">
+        <span class="cun-label">方法一(用户输入)</span>
+        <span class="cun-val">{{ fmt(result.userFingerCunMm) }} mm</span>
+      </div>
+      <div class="cun-row">
+        <span class="cun-label">方法二(标准人换算)</span>
+        <span class="cun-val">{{ fmt(result.derivedFingerCunMm) }} mm</span>
+      </div>
+      <div class="cun-row cun-final">
+        <span class="cun-label">最终指寸</span>
+        <span class="cun-val">{{ fmt(result.finalFingerCunMm) }} mm</span>
+      </div>
+    </div>
+    </div>
+
+    <div class="preview-model-col">
+    <div class="model-section-title">穴位定位示意图(背部:与您面对患者后背时的左右一致)</div>
+    <div class="body-model-container">
+      <div class="body-canvas-wrap">
+        <svg viewBox="0 0 340 520" xmlns="http://www.w3.org/2000/svg" class="body-svg">
+        <defs>
+          <pattern id="loc-grid" width="24" height="24" patternUnits="userSpaceOnUse">
+            <path d="M 24 0 L 0 0 0 24" fill="none" stroke="rgba(99,102,241,0.06)" stroke-width="1" />
+          </pattern>
+          <radialGradient id="canvasWash" cx="50%" cy="18%" r="75%">
+            <stop offset="0%" stop-color="#f8fafc" />
+            <stop offset="55%" stop-color="#f1f5f9" />
+            <stop offset="100%" stop-color="#e8eef5" />
+          </radialGradient>
+          <linearGradient id="bodyGrad" x1="0%" y1="0%" x2="100%" y2="100%">
+            <stop offset="0%" stop-color="#fdf8f5" />
+            <stop offset="35%" stop-color="#f5ebe3" />
+            <stop offset="100%" stop-color="#e8ddd4" />
+          </linearGradient>
+          <linearGradient id="bodyStrokeGrad" x1="0%" y1="0%" x2="0%" y2="100%">
+            <stop offset="0%" stop-color="#c9b8a8" />
+            <stop offset="100%" stop-color="#a8988a" />
+          </linearGradient>
+          <filter id="bodyShadow" x="-25%" y="-15%" width="150%" height="130%">
+            <feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#5c4a3d" flood-opacity="0.12" />
+          </filter>
+        </defs>
+
+        <rect width="100%" height="100%" fill="url(#canvasWash)" rx="14" />
+        <rect width="100%" height="100%" fill="url(#loc-grid)" rx="14" opacity="0.85" />
+
+        <line class="axis-line axis-v" x1="170" y1="12" x2="170" y2="508" />
+        <line class="axis-line axis-h" x1="12" y1="85" x2="328" y2="85" />
+
+        <g class="body-silhouette" filter="url(#bodyShadow)" stroke="url(#bodyStrokeGrad)" stroke-width="1.35" stroke-linejoin="round" stroke-linecap="round">
+          <path d="
+            M 170 18
+            C 158 18, 148 26, 146 42
+            C 144 54, 148 68, 152 78
+            C 154 84, 138 88, 118 94
+            C 98 102, 84 112, 76 124
+            C 70 138, 66 158, 64 182
+            C 62 210, 58 242, 54 268
+            C 50 288, 46 298, 52 304
+            C 58 310, 72 302, 82 282
+            C 92 258, 96 228, 100 198
+            C 104 172, 108 152, 112 158
+            C 116 168, 118 198, 120 232
+            C 124 278, 128 318, 130 348
+            C 132 388, 126 438, 120 472
+            C 116 492, 112 504, 124 508
+            C 138 512, 148 508, 152 486
+            C 158 448, 164 392, 170 368
+            C 176 392, 182 448, 188 486
+            C 192 508, 202 512, 216 508
+            C 228 504, 224 492, 220 472
+            C 214 438, 208 388, 210 348
+            C 212 318, 216 278, 220 232
+            C 222 198, 224 168, 228 158
+            C 232 152, 236 172, 240 198
+            C 244 228, 248 258, 258 282
+            C 268 302, 282 310, 288 304
+            C 294 298, 290 288, 286 268
+            C 282 242, 278 210, 276 182
+            C 274 158, 270 138, 264 124
+            C 256 112, 242 102, 222 94
+            C 202 88, 186 84, 188 78
+            C 192 68, 196 54, 194 42
+            C 192 26, 182 18, 170 18
+            Z" fill="url(#bodyGrad)" />
+
+          <path class="body-detail" d="M 128 102 C 118 108, 108 128, 114 158 C 118 172, 132 176, 138 162 C 144 142, 142 118, 128 102 Z" fill="none" stroke="rgba(92,74,61,0.2)" stroke-width="1.2" />
+          <path class="body-detail" d="M 212 102 C 222 108, 232 128, 226 158 C 222 172, 208 176, 202 162 C 196 142, 198 118, 212 102 Z" fill="none" stroke="rgba(92,74,61,0.2)" stroke-width="1.2" />
+          <path class="body-detail" d="M 128 312 C 148 338, 162 348, 170 342 C 178 348, 192 338, 212 312" fill="none" stroke="rgba(92,74,61,0.16)" stroke-width="1.2" />
+        </g>
+
+        <line class="spine-guide" x1="170" y1="75" x2="170" y2="330" />
+
+        <g class="c7-marker" transform="translate(170, 85)">
+          <circle class="c7-pulse" cx="0" cy="0" r="14" fill="none" stroke="#e85d5d" stroke-width="1" opacity="0.35" />
+          <circle cx="0" cy="0" r="5" fill="#fff" stroke="#e85d5d" stroke-width="2" />
+          <line x1="-11" y1="0" x2="-5" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+          <line x1="5" y1="0" x2="11" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+          <line x1="0" y1="-11" x2="0" y2="-5" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+          <line x1="0" y1="5" x2="0" y2="11" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+          <text x="15" y="4" class="c7-label">C7 大椎原点</text>
+        </g>
+
+        <text x="176" y="22" class="axis-label">↑ 头侧 (+Y)</text>
+        <text x="176" y="508" class="axis-label">↓ 尾侧 (-Y)</text>
+        <text x="14" y="78" class="axis-label">患者左 (−X)</text>
+        <text x="326" y="78" text-anchor="end" class="axis-label">患者右 (+X)</text>
+
+        <g v-for="(m, idx) in markers" :key="idx" class="acu-marker">
+          <circle :cx="m.x" :cy="m.y" r="10" :fill="m.color" opacity="0.22" />
+          <circle :cx="m.x" :cy="m.y" r="5.5" :fill="m.color" stroke="#fff" stroke-width="2" class="marker-dot" />
+          <text
+            :x="m.x" :y="m.y + 3.8"
+            text-anchor="middle"
+            class="marker-index"
+          >
+            {{ m.num }}
+            <title>{{ m.tip }}</title>
+          </text>
+          <text
+            :x="m.labelX" :y="m.y + 4"
+            :text-anchor="m.anchor"
+            class="marker-name"
+            :fill="m.color"
+          >
+            {{ m.name }}
+            <title>{{ m.tip }}</title>
+          </text>
+        </g>
+
+        <g v-if="!markers.length" class="empty-hint">
+          <rect x="48" y="228" width="244" height="52" rx="26" class="empty-hint-bg" />
+          <text x="170" y="252" text-anchor="middle" class="empty-hint-title">尚未定位</text>
+          <text x="170" y="268" text-anchor="middle" class="empty-hint-sub">点击「调用预览」在模型上标注穴位</text>
+        </g>
+      </svg>
+      </div>
+
+      <!-- 图例 -->
+      <div v-if="resultItems.length" class="marker-legend">
+        <div class="legend-title">穴位坐标对比</div>
+        <div class="legend-chips">
+          <span class="chip chip-ok"><i class="chip-dot" /> 通过</span>
+          <span class="chip chip-bad"><i class="chip-dot" /> 越界</span>
+          <span class="chip chip-muted"><i class="chip-dot" /> 其他</span>
+        </div>
+        <div class="legend-table-wrap">
+        <table class="legend-table">
+          <thead>
+            <tr>
+              <th>#</th>
+              <th>穴位</th>
+              <th v-if="hasDualMethod" colspan="2">方法一(用户指寸)</th>
+              <th colspan="2">方法二(标准换算)</th>
+              <th colspan="2">最终(均值)</th>
+              <th>状态</th>
+            </tr>
+            <tr class="sub-header">
+              <th></th>
+              <th></th>
+              <th v-if="hasDualMethod">X</th>
+              <th v-if="hasDualMethod">Y</th>
+              <th>X</th>
+              <th>Y</th>
+              <th>X</th>
+              <th>Y</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="(item, idx) in resultItems" :key="idx">
+              <td>{{ idx + 1 }}</td>
+              <td>{{ item.acupointName || item.acupointCode }}{{ sideLabel(item.side) }}</td>
+              <td v-if="hasDualMethod" class="num">{{ fmt(item.method1BodyX) }}</td>
+              <td v-if="hasDualMethod" class="num">{{ fmt(item.method1BodyY) }}</td>
+              <td class="num">{{ fmt(item.method2BodyX) }}</td>
+              <td class="num">{{ fmt(item.method2BodyY) }}</td>
+              <td class="num final-col">{{ fmt(item.bodyX) }}</td>
+              <td class="num final-col">{{ fmt(item.bodyY) }}</td>
+              <td>
+                <span :style="{ color: statusColor(item.validationStatus) }">
+                  {{ statusText(item.validationStatus) }}
+                </span>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        </div>
+      </div>
+    </div>
+    </div>
+    </div>
+
+    <el-divider>响应 data</el-divider>
+    <pre class="json">{{ resultText }}</pre>
+  </el-card>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import http from '../api/http'
+
+const selectedCodes = ref(['dumai-14'])
+const acupointOptions = ref([])
+const optionsLoading = ref(false)
+const userOptions = ref([])
+const usersLoading = ref(false)
+const formRef = ref(null)
+
+const form = reactive({
+  deviceModelCode: '',
+  user: {
+    userId: undefined,
+    heightCm: '',
+    weightKg: '',
+    shoulderWidthCm: '',
+    spineLengthCm: '',
+    fingerCunCm: '',
+    gender: '',
+  },
+})
+
+const formRules = {
+  shoulderWidthCm: [{ required: true, message: '肩宽为必填项', trigger: 'blur' }],
+  spineLengthCm: [{ required: true, message: '脊柱长度为必填项', trigger: 'blur' }],
+}
+
+const loading = ref(false)
+const result = ref(null)
+
+const resultText = computed(() => {
+  if (result.value == null) return '(暂无)'
+  try { return JSON.stringify(result.value, null, 2) }
+  catch { return String(result.value) }
+})
+
+const resultItems = computed(() => {
+  const items = result.value?.items
+  return Array.isArray(items) ? items : []
+})
+
+const hasDualMethod = computed(() => {
+  return resultItems.value.some((it) => it.method1BodyX != null)
+})
+
+// ── 坐标映射(人体 mm → SVG px) ──
+const SVG_C7_X = 170
+const SVG_C7_Y = 85
+const SCALE = 0.36
+
+function bodyToSvg(bodyX, bodyY) {
+  return {
+    x: SVG_C7_X + bodyX * SCALE,
+    y: SVG_C7_Y - bodyY * SCALE,
+  }
+}
+
+const markers = computed(() => {
+  return resultItems.value
+    .filter((it) => it.bodyX != null && it.bodyY != null)
+    .map((it, idx) => {
+      const { x, y } = bodyToSvg(Number(it.bodyX), Number(it.bodyY))
+      const ok = it.validationStatus === 'VALIDATION_OK'
+      const bad = it.validationStatus === 'OUT_OF_RANGE'
+      const color = ok ? '#2d9d5a' : bad ? '#e85d5d' : '#7a8494'
+      const onRight = x > SVG_C7_X
+      const name = (it.acupointName || it.acupointCode) + sideLabel(it.side)
+      return {
+        num: idx + 1,
+        x, y, color, name,
+        labelX: onRight ? x - 12 : x + 12,
+        anchor: onRight ? 'end' : 'start',
+        tip: `${it.acupointCode}${sideLabel(it.side)}  最终=(${it.bodyX}, ${it.bodyY})  device=(${it.deviceX}, ${it.deviceY})  ${it.validationStatus}`,
+      }
+    })
+})
+
+function sideLabel(side) {
+  if (!side || side === 'CENTER') return ''
+  return side === 'LEFT' ? '·左' : '·右'
+}
+
+function statusColor(s) {
+  if (s === 'VALIDATION_OK') return '#2d9d5a'
+  if (s === 'OUT_OF_RANGE') return '#e85d5d'
+  return '#7a8494'
+}
+
+function statusText(s) {
+  if (s === 'VALIDATION_OK') return '✓'
+  if (s === 'OUT_OF_RANGE') return '越界'
+  if (s === 'ACUPOINT_NOT_FOUND') return '未找到'
+  if (s === 'NO_COORDINATES') return '无坐标'
+  return s || '-'
+}
+
+function fmt(v) {
+  return v != null ? Number(v).toFixed(2) : '-'
+}
+
+// ── 用户下拉 ──
+async function loadUserOptions() {
+  usersLoading.value = true
+  try {
+    const res = await http.get('/users', { params: { current: 1, size: 500 } })
+    userOptions.value = Array.isArray(res.data?.records) ? res.data.records : []
+  } catch {
+    userOptions.value = []
+  } finally {
+    usersLoading.value = false
+  }
+}
+
+function onUserSelect(userId) {
+  if (!userId) {
+    form.user.heightCm = ''
+    form.user.weightKg = ''
+    form.user.shoulderWidthCm = ''
+    form.user.spineLengthCm = ''
+    form.user.fingerCunCm = ''
+    form.user.gender = ''
+    return
+  }
+  const u = userOptions.value.find((x) => x.id === userId)
+  if (!u) return
+  form.user.heightCm = ''
+  form.user.weightKg = ''
+  form.user.shoulderWidthCm = u.shoulderWidth ?? ''
+  form.user.spineLengthCm = u.bodyLength ?? ''
+  form.user.fingerCunCm = u.fingerCun1 ?? ''
+  form.user.gender = u.gender ?? ''
+}
+
+// ── 穴位下拉 ──
+async function loadAcupointOptions() {
+  optionsLoading.value = true
+  try {
+    const res = await http.get('/acupoints', { params: { current: 1, size: 5000 } })
+    const records = res.data?.records ?? []
+    acupointOptions.value = records
+      .filter((a) => a.code)
+      .map((a) => ({ code: a.code, label: `${a.name || '(未命名)'} · ${a.code}` }))
+      .sort((a, b) => a.code.localeCompare(b.code, 'zh-Hans-CN'))
+  } catch {
+    acupointOptions.value = []
+  } finally {
+    optionsLoading.value = false
+  }
+}
+
+onMounted(() => {
+  loadAcupointOptions()
+  loadUserOptions()
+})
+
+// ── 调用预览 ──
+async function preview() {
+  if (!form.user.userId) {
+    result.value = { error: '请先选择一个用户' }
+    return
+  }
+
+  loading.value = true
+  try {
+    const res = await http.post(`/users/${form.user.userId}/acupoint-coordinates`)
+    result.value = res.data
+  } catch (e) {
+    result.value = { error: e.message }
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped>
+.modern-card {
+  border: none;
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
+}
+.preview-main {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: 28px 36px;
+}
+
+.preview-form-col {
+  flex: 1 1 320px;
+  min-width: 260px;
+  max-width: 640px;
+}
+
+.preview-model-col {
+  flex: 0 1 auto;
+  min-width: 280px;
+  max-width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  position: sticky;
+  top: 12px;
+  align-self: flex-start;
+}
+
+.model-section-title {
+  margin: 0;
+  padding-bottom: 10px;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  letter-spacing: 0.02em;
+}
+
+.form {
+  max-width: none;
+}
+.json {
+  background: #1e1e1e;
+  color: #d4d4d4;
+  padding: 16px;
+  border-radius: 8px;
+  overflow: auto;
+  max-height: 480px;
+  font-size: 13px;
+  line-height: 1.5;
+}
+
+/* ── 指寸摘要 ── */
+.cun-summary {
+  margin-top: 16px;
+  padding: 14px 18px;
+  background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
+  border: 1px solid #bbf7d0;
+  border-radius: 10px;
+}
+.cun-summary-title {
+  font-size: 13px;
+  font-weight: 700;
+  color: #166534;
+  margin-bottom: 8px;
+}
+.cun-row {
+  display: flex;
+  justify-content: space-between;
+  font-size: 13px;
+  padding: 3px 0;
+  color: #334155;
+}
+.cun-label { color: #64748b; }
+.cun-val { font-family: ui-monospace, monospace; font-weight: 600; }
+.cun-final {
+  border-top: 1px dashed #86efac;
+  padding-top: 6px;
+  margin-top: 4px;
+  font-weight: 700;
+}
+.cun-final .cun-label { color: #166534; }
+
+/* ── 人体模型区域 ── */
+.body-model-container {
+  display: flex;
+  gap: 28px;
+  align-items: flex-start;
+  flex-wrap: wrap;
+}
+
+.body-canvas-wrap {
+  padding: 4px;
+  background: linear-gradient(145deg, #fff 0%, #f4f6f9 100%);
+  border-radius: 18px;
+  box-shadow:
+    0 1px 2px rgba(15, 23, 42, 0.04),
+    0 12px 40px -12px rgba(15, 23, 42, 0.12);
+  border: 1px solid rgba(255, 255, 255, 0.8);
+}
+
+.body-svg {
+  display: block;
+  width: 320px;
+  min-width: 260px;
+  max-width: 100%;
+  border-radius: 14px;
+  vertical-align: top;
+}
+
+.body-svg :deep(.axis-line) {
+  stroke: rgba(148, 163, 184, 0.45);
+  stroke-width: 1;
+}
+.body-svg :deep(.axis-v) {
+  stroke-dasharray: 4 6;
+}
+.body-svg :deep(.axis-h) {
+  stroke-dasharray: 5 5;
+}
+.body-svg :deep(.axis-label) {
+  font-size: 10px;
+  fill: #94a3b8;
+  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
+  letter-spacing: 0.02em;
+}
+.body-svg :deep(.spine-guide) {
+  stroke: rgba(99, 102, 241, 0.38);
+  stroke-width: 2;
+  stroke-dasharray: 8 6;
+  stroke-linecap: round;
+  animation: spine-dash 24s linear infinite;
+}
+@keyframes spine-dash {
+  to {
+    stroke-dashoffset: -420;
+  }
+}
+.body-svg :deep(.c7-label) {
+  font-size: 11px;
+  fill: #c24141;
+  font-weight: 700;
+  font-family: system-ui, -apple-system, sans-serif;
+}
+.body-svg :deep(.c7-pulse) {
+  transform-box: fill-box;
+  transform-origin: center;
+  animation: c7-pulse 2.2s ease-out infinite;
+}
+@keyframes c7-pulse {
+  0% {
+    transform: scale(0.55);
+    opacity: 0.55;
+  }
+  100% {
+    transform: scale(1.35);
+    opacity: 0;
+  }
+}
+.body-svg :deep(.marker-index) {
+  font-size: 8px;
+  font-weight: 800;
+  fill: #fff;
+  font-family: system-ui, sans-serif;
+  pointer-events: none;
+}
+.body-svg :deep(.marker-name) {
+  font-size: 10px;
+  font-weight: 700;
+  font-family: system-ui, sans-serif;
+  paint-order: stroke fill;
+  stroke: rgba(255, 255, 255, 0.92);
+  stroke-width: 3px;
+  stroke-linejoin: round;
+}
+.body-svg :deep(.empty-hint-bg) {
+  fill: rgba(255, 255, 255, 0.72);
+  stroke: rgba(148, 163, 184, 0.35);
+  stroke-width: 1;
+  filter: drop-shadow(0 4px 20px rgba(15, 23, 42, 0.08));
+}
+.body-svg :deep(.empty-hint-title) {
+  font-size: 13px;
+  fill: #475569;
+  font-weight: 600;
+  font-family: system-ui, sans-serif;
+}
+.body-svg :deep(.empty-hint-sub) {
+  font-size: 11px;
+  fill: #94a3b8;
+  font-family: system-ui, sans-serif;
+}
+
+.marker-legend {
+  flex: 1;
+  min-width: 280px;
+  max-width: 620px;
+  padding: 16px 18px;
+  background: linear-gradient(180deg, #fafbfc 0%, #fff 40%);
+  border-radius: 14px;
+  border: 1px solid #eef0f4;
+  box-shadow: 0 2px 16px rgba(15, 23, 42, 0.06);
+}
+.legend-title {
+  font-size: 14px;
+  font-weight: 700;
+  color: #1e293b;
+  margin-bottom: 10px;
+  letter-spacing: 0.02em;
+}
+.legend-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 14px;
+}
+.chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #64748b;
+  padding: 4px 10px;
+  border-radius: 999px;
+  background: #f1f5f9;
+  border: 1px solid #e2e8f0;
+}
+.chip-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  display: inline-block;
+}
+.chip-ok .chip-dot {
+  background: #2d9d5a;
+  box-shadow: 0 0 0 2px rgba(45, 157, 90, 0.2);
+}
+.chip-bad .chip-dot {
+  background: #e85d5d;
+  box-shadow: 0 0 0 2px rgba(232, 93, 93, 0.2);
+}
+.chip-muted .chip-dot {
+  background: #7a8494;
+}
+
+.legend-table-wrap {
+  overflow-x: auto;
+}
+.legend-table {
+  width: 100%;
+  border-collapse: separate;
+  border-spacing: 0;
+  font-size: 12px;
+  border-radius: 10px;
+  overflow: hidden;
+  border: 1px solid #eef0f4;
+  white-space: nowrap;
+}
+.legend-table th,
+.legend-table td {
+  padding: 7px 8px;
+  text-align: left;
+  border-bottom: 1px solid #f1f5f9;
+}
+.legend-table tr:last-child td {
+  border-bottom: none;
+}
+.legend-table th {
+  background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
+  font-weight: 600;
+  color: #475569;
+  font-size: 11px;
+}
+.legend-table .sub-header th {
+  font-size: 10px;
+  font-weight: 500;
+  color: #94a3b8;
+  padding: 2px 8px;
+}
+.legend-table tbody tr:hover td {
+  background: #f8fafc;
+}
+.legend-table td.num {
+  text-align: right;
+  font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
+  font-size: 11px;
+  color: #334155;
+}
+.legend-table td.final-col {
+  font-weight: 700;
+  color: #166534;
+}
+</style>

+ 120 - 0
openspec/aijiuyiBackend/admin-ui/src/views/Login.vue

@@ -0,0 +1,120 @@
+<template>
+  <div class="login-page">
+    <div class="login-container">
+      <div class="login-header">
+        <h2>艾灸 PC 后台</h2>
+        <p>欢迎回来,请登录您的账号</p>
+      </div>
+      <el-card class="login-card" :body-style="{ padding: '32px' }">
+        <el-form :model="form" :rules="rules" ref="formRef" label-width="0" size="large" @submit.prevent>
+          <el-form-item prop="username">
+            <el-input v-model="form.username" placeholder="用户名" autocomplete="username" />
+          </el-form-item>
+          <el-form-item prop="password">
+            <el-input v-model="form.password" type="password" placeholder="密码" show-password autocomplete="current-password" />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" :loading="loading" class="login-btn" @click="submit">登 录</el-button>
+          </el-form-item>
+        </el-form>
+        <p class="hint">默认账号见后端 db/migration 种子 SQL(一般为 admin / admin)</p>
+      </el-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import http from '../api/http'
+
+const router = useRouter()
+const route = useRoute()
+const formRef = ref()
+const loading = ref(false)
+const form = reactive({
+  username: '',
+  password: '',
+})
+const rules = {
+  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+}
+
+async function submit() {
+  await formRef.value?.validate()
+  loading.value = true
+  try {
+    const res = await http.post('/auth/login', {
+      username: form.username,
+      password: form.password,
+    })
+    const data = res.data
+    localStorage.setItem('admin_token', data.token)
+    localStorage.setItem('admin_username', data.username)
+    localStorage.setItem('admin_roles', data.role || '')
+    const redirect = route.query.redirect || '/home'
+    router.replace(typeof redirect === 'string' ? redirect : '/home')
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped>
+.login-page {
+  min-height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f0fdf4;
+  background-image: radial-gradient(circle at 50% 0%, #dcfce7 0%, transparent 50%),
+                    radial-gradient(circle at 0% 50%, #e0f2fe 0%, transparent 50%);
+}
+.login-container {
+  width: 400px;
+  max-width: 92vw;
+}
+.login-header {
+  text-align: center;
+  margin-bottom: 24px;
+}
+.login-header h2 {
+  margin: 0;
+  font-size: 28px;
+  color: #0f172a;
+  font-weight: 600;
+  letter-spacing: 1px;
+}
+.login-header p {
+  margin: 8px 0 0;
+  font-size: 14px;
+  color: #64748b;
+}
+.login-card {
+  border: none;
+  border-radius: 16px;
+  box-shadow: 0 10px 25px rgba(16, 185, 129, 0.1);
+  background: rgba(255, 255, 255, 0.9);
+  backdrop-filter: blur(10px);
+}
+.login-btn {
+  width: 100%;
+  height: 44px;
+  font-size: 16px;
+  font-weight: 500;
+  border-radius: 8px;
+  margin-top: 8px;
+  transition: all 0.3s ease;
+}
+.login-btn:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
+}
+.hint {
+  margin: 16px 0 0;
+  font-size: 12px;
+  color: #94a3b8;
+  text-align: center;
+}
+</style>

+ 86 - 0
openspec/aijiuyiBackend/admin-ui/src/views/OperationLogsView.vue

@@ -0,0 +1,86 @@
+<template>
+  <el-card shadow="never" class="modern-card">
+    <el-table :data="rows" class="modern-table" v-loading="loading" style="width: 100%">
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="operatorName" label="操作人" width="120" />
+      <el-table-column prop="actionType" label="动作类型" width="100" />
+      <el-table-column prop="module" label="模块" width="120" />
+      <el-table-column prop="content" label="操作内容" min-width="200" show-overflow-tooltip />
+      <el-table-column prop="ip" label="IP" width="130" />
+      <el-table-column prop="createTime" label="时间" width="170" />
+    </el-table>
+    <el-pagination
+      class="pager"
+      background
+      layout="total, prev, pager, next"
+      :total="total"
+      v-model:current-page="page.current"
+      :page-size="page.size"
+      @current-change="fetchList"
+    />
+  </el-card>
+</template>
+
+<script setup>
+import { reactive, ref } from 'vue'
+import http from '../api/http'
+
+const rows = ref([])
+const total = ref(0)
+const loading = ref(false)
+const page = reactive({ current: 1, size: 20 })
+
+async function fetchList() {
+  loading.value = true
+  try {
+    const res = await http.get('/operation-logs', {
+      params: { current: page.current, size: page.size },
+    })
+    const pr = res.data || {}
+    rows.value = pr.records || []
+    total.value = pr.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+fetchList()
+</script>
+
+<style scoped>
+.modern-card {
+  min-height: 400px;
+  border: none;
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
+  padding: 4px;
+}
+.pager {
+  margin-top: 24px;
+  justify-content: flex-end;
+  padding-right: 4px;
+}
+
+/* Modern Table Overrides */
+.modern-table {
+  border-radius: 8px;
+  overflow: hidden;
+}
+.modern-table :deep(.el-table__inner-wrapper::before) {
+  display: none;
+}
+.modern-table :deep(th.el-table__cell) {
+  background-color: #f8fafc;
+  color: #475569;
+  font-weight: 600;
+  border-bottom: 1px solid #e2e8f0;
+  padding: 12px 0;
+}
+.modern-table :deep(td.el-table__cell) {
+  border-bottom: 1px solid #f1f5f9;
+  padding: 12px 0;
+}
+.modern-table :deep(.el-table__row:hover > td.el-table__cell) {
+  background-color: #f8fafc;
+}
+</style>

+ 136 - 0
openspec/aijiuyiBackend/admin-ui/src/views/SchemeDetailView.vue

@@ -0,0 +1,136 @@
+<template>
+  <div v-loading="loading">
+    <el-page-header @back="goBack" style="margin-bottom: 20px">
+      <template #content><span class="page-title">方案详情</span></template>
+    </el-page-header>
+
+    <template v-if="detail">
+      <!-- 基本信息 -->
+      <el-card shadow="never" class="section-card">
+        <template #header><span class="section-title">基本信息</span></template>
+        <el-descriptions :column="3" border>
+          <el-descriptions-item label="方案ID">{{ detail.scheme.id }}</el-descriptions-item>
+          <el-descriptions-item label="方案名称">{{ detail.scheme.schemeName }}</el-descriptions-item>
+          <el-descriptions-item label="模式类型">{{ detail.scheme.mode }}</el-descriptions-item>
+          <el-descriptions-item label="功效类型">{{ detail.scheme.efficacyType || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="创作人">{{ detail.scheme.creatorName }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <el-tag :type="detail.scheme.status === 'published' ? 'success' : detail.scheme.status === 'archived' ? 'info' : 'warning'">
+              {{ detail.scheme.status }}
+            </el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="适用性别">{{ detail.scheme.targetGender }}</el-descriptions-item>
+          <el-descriptions-item label="适用年龄">{{ detail.scheme.ageMin }}-{{ detail.scheme.ageMax }}岁</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ detail.scheme.createTime }}</el-descriptions-item>
+          <el-descriptions-item label="方案描述" :span="3">{{ detail.scheme.description || '-' }}</el-descriptions-item>
+        </el-descriptions>
+      </el-card>
+
+      <!-- 治疗步骤 -->
+      <el-card shadow="never" class="section-card">
+        <template #header>
+          <div class="section-header">
+            <span class="section-title">治疗步骤</span>
+            <span class="section-info">总时长:{{ Math.round(detail.totalDurationSeconds / 60) }} 分钟</span>
+          </div>
+        </template>
+        <el-table :data="detail.steps" class="modern-table" style="width: 100%">
+          <el-table-column prop="stepOrder" label="步骤" width="70" />
+          <el-table-column prop="acupointName" label="穴位名称" width="120" />
+          <el-table-column prop="side" label="侧别" width="80" />
+          <el-table-column prop="temperature" label="温度(°C)" width="100" />
+          <el-table-column label="时间" width="100">
+            <template #default="{ row }">{{ Math.round(row.duration / 60) }}分钟</template>
+          </el-table-column>
+          <el-table-column prop="techniqueName" label="艾灸手法" width="120" />
+          <el-table-column prop="remark" label="备注" min-width="120" />
+        </el-table>
+      </el-card>
+
+      <!-- 使用统计 -->
+      <el-card shadow="never" class="section-card">
+        <template #header><span class="section-title">使用统计</span></template>
+        <el-descriptions :column="4" border>
+          <el-descriptions-item label="使用次数">{{ detail.statistics?.totalSessions || 0 }}</el-descriptions-item>
+          <el-descriptions-item label="使用人数">{{ detail.statistics?.userCount || 0 }}</el-descriptions-item>
+          <el-descriptions-item label="平均评分">{{ detail.statistics?.averageRating || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="最后使用">{{ detail.statistics?.lastUsedAt || '-' }}</el-descriptions-item>
+        </el-descriptions>
+      </el-card>
+
+      <!-- 最近使用记录 -->
+      <el-card v-if="detail.recentRecords?.length" shadow="never" class="section-card">
+        <template #header><span class="section-title">最近使用记录(最近10条)</span></template>
+        <el-table :data="detail.recentRecords" class="modern-table" style="width: 100%">
+          <el-table-column prop="userId" label="用户ID" width="100" />
+          <el-table-column prop="deviceId" label="设备ID" width="100" />
+          <el-table-column prop="startedAt" label="开始时间" width="170" />
+          <el-table-column label="时长" width="100">
+            <template #default="{ row }">{{ row.duration ? Math.round(row.duration / 60) + '分钟' : '-' }}</template>
+          </el-table-column>
+          <el-table-column prop="rating" label="评分" width="80" />
+          <el-table-column prop="remark" label="备注" min-width="120" />
+        </el-table>
+      </el-card>
+
+      <!-- 操作按钮 -->
+      <div class="action-bar">
+        <el-button v-if="detail.scheme.status === 'draft'" type="primary" @click="publishScheme">发布方案</el-button>
+        <el-button v-if="detail.scheme.status === 'published'" type="warning" @click="archiveScheme">下架方案</el-button>
+        <el-button @click="goBack">返回</el-button>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import http from '../api/http'
+
+const route = useRoute()
+const router = useRouter()
+const loading = ref(false)
+const detail = ref(null)
+const schemeId = route.params.schemeId
+
+async function fetchDetail() {
+  loading.value = true
+  try {
+    const res = await http.get(`/schemes/${schemeId}/detail`)
+    detail.value = res.data
+  } finally {
+    loading.value = false
+  }
+}
+
+async function publishScheme() {
+  await http.post(`/schemes/${schemeId}/publish`)
+  ElMessage.success('发布成功')
+  await fetchDetail()
+}
+
+async function archiveScheme() {
+  await http.post(`/schemes/${schemeId}/archive`)
+  ElMessage.success('下架成功')
+  await fetchDetail()
+}
+
+function goBack() {
+  router.push('/crud/schemes')
+}
+
+onMounted(fetchDetail)
+</script>
+
+<style scoped>
+.page-title { font-size: 18px; font-weight: 600; color: #334155; }
+.section-card { border: none; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.02); margin-bottom: 16px; }
+.section-title { font-weight: 600; color: #334155; }
+.section-header { display: flex; justify-content: space-between; align-items: center; }
+.section-info { color: #64748b; font-size: 14px; }
+.action-bar { display: flex; gap: 12px; margin-top: 16px; justify-content: flex-end; }
+.modern-table { border-radius: 8px; overflow: hidden; }
+.modern-table :deep(th.el-table__cell) { background-color: #f8fafc; color: #475569; font-weight: 600; }
+</style>

+ 753 - 0
openspec/aijiuyiBackend/admin-ui/src/views/SchemeSimulationView.vue

@@ -0,0 +1,753 @@
+<template>
+  <div class="simulation-page">
+    <el-page-header @back="() => $router.push('/home')" style="margin-bottom: 20px">
+      <template #content><span class="page-title">方案模拟测试</span></template>
+      <template #extra>
+        <el-tag type="info" effect="plain">模拟小程序选择手法/模式后,系统对穴位的判断结果</el-tag>
+      </template>
+    </el-page-header>
+
+    <div class="sim-layout">
+      <!-- 左侧:配置面板 -->
+      <div class="sim-config">
+        <el-card shadow="never" class="config-card">
+          <template #header><span class="card-title">模拟配置</span></template>
+
+          <!-- 用户选择 -->
+          <el-form label-width="90px" class="config-form">
+            <el-form-item label="选择用户">
+              <el-select
+                v-model="selectedUserId"
+                filterable
+                clearable
+                placeholder="选择已有用户(自动填充体型数据)"
+                style="width: 100%"
+                :loading="usersLoading"
+                @change="onUserSelect"
+              >
+                <el-option
+                  v-for="u in userOptions"
+                  :key="u.id"
+                  :label="`${u.name || '未命名'} (${u.gender === 'male' ? '男' : '女'}, ${u.shoulderWidth || '-'}cm)`"
+                  :value="u.id"
+                />
+              </el-select>
+            </el-form-item>
+
+            <el-divider content-position="left">体型参数</el-divider>
+
+            <el-row :gutter="12">
+              <el-col :span="12">
+                <el-form-item label="性别">
+                  <el-select v-model="bodyForm.gender" style="width: 100%" disabled>
+                    <el-option label="男" value="male" />
+                    <el-option label="女" value="female" />
+                  </el-select>
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="年龄">
+                  <el-input v-model="bodyForm.age" disabled />
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="12">
+              <el-col :span="12">
+                <el-form-item label="肩宽(cm)">
+                  <el-input v-model="bodyForm.shoulderWidth" disabled />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="身长(cm)">
+                  <el-input v-model="bodyForm.bodyLength" disabled />
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="12">
+              <el-col :span="8">
+                <el-form-item label="一寸">
+                  <el-input v-model="bodyForm.fingerCun1" disabled placeholder="-" />
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="1.5寸">
+                  <el-input v-model="bodyForm.fingerCun1_5" disabled placeholder="-" />
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="三寸">
+                  <el-input v-model="bodyForm.fingerCun3" disabled placeholder="-" />
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <div v-if="bodyForm.acupointTableStatus" class="status-badge">
+              <el-tag :type="bodyForm.acupointTableStatus === 'GENERATED' ? 'success' : 'warning'" size="small">
+                穴位表: {{ bodyForm.acupointTableStatus === 'GENERATED' ? '已生成' : '未生成' }}
+              </el-tag>
+            </div>
+
+            <el-divider content-position="left">选择模式与方案</el-divider>
+
+            <el-form-item label="艾灸模式">
+              <el-select v-model="selectedMode" placeholder="选择模式" style="width: 100%" @change="onModeChange">
+                <el-option label="一键艾灸(默认)" value="ONE_CLICK" />
+                <el-option label="专业模式(8种功效)" value="PROFESSIONAL" />
+                <el-option label="自定义模式" value="CUSTOM" />
+                <el-option label="延年圣手模式(专家方案)" value="EXPERT" />
+              </el-select>
+            </el-form-item>
+
+            <el-form-item v-if="selectedMode === 'PROFESSIONAL'" label="功效类型">
+              <el-select v-model="selectedEfficacy" placeholder="选择功效" style="width: 100%" @change="onEfficacyChange">
+                <el-option v-for="e in efficacyTypes" :key="e" :label="e" :value="e" />
+              </el-select>
+            </el-form-item>
+
+            <el-form-item label="选择方案">
+              <el-select
+                v-model="selectedSchemeId"
+                filterable
+                placeholder="选择具体方案"
+                style="width: 100%"
+                :loading="schemesLoading"
+                :disabled="!filteredSchemes.length"
+              >
+                <el-option
+                  v-for="s in filteredSchemes"
+                  :key="s.id"
+                  :label="`${s.schemeName} (${s.efficacyType || s.mode})`"
+                  :value="s.id"
+                />
+              </el-select>
+            </el-form-item>
+
+            <el-form-item>
+              <el-button
+                type="primary"
+                size="large"
+                :loading="simulating"
+                :disabled="!canSimulate"
+                @click="runSimulation"
+                style="width: 100%"
+              >
+                开始模拟测试
+              </el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 方案步骤结果 -->
+        <el-card v-if="pushData" shadow="never" class="result-card steps-card">
+          <template #header>
+            <div class="card-header-row">
+              <span class="card-title">模拟结果 — 治疗步骤</span>
+              <el-tag effect="plain" size="small">
+                总时长: {{ totalMinutes }} 分钟 / {{ pushData.scheme?.steps?.length || 0 }} 步
+              </el-tag>
+            </div>
+          </template>
+          <el-table :data="pushData.scheme?.steps || []" class="modern-table" size="small" stripe>
+            <el-table-column prop="stepOrder" label="#" width="45" align="center" />
+            <el-table-column prop="acupointName" label="穴位" width="100" />
+            <el-table-column label="偏移X(mm)" width="90" align="right">
+              <template #default="{ row }">
+                <span class="num-cell">{{ fmtNum(row.offsetX) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="偏移Y(mm)" width="90" align="right">
+              <template #default="{ row }">
+                <span class="num-cell">{{ fmtNum(row.offsetY) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column prop="temperature" label="温度°C" width="75" align="center" />
+            <el-table-column label="时长" width="70" align="center">
+              <template #default="{ row }">{{ Math.round(row.duration / 60) }}分</template>
+            </el-table-column>
+            <el-table-column prop="techniqueName" label="手法" min-width="80" />
+          </el-table>
+        </el-card>
+      </div>
+
+      <!-- 右侧:可视化 -->
+      <div class="sim-visual">
+        <el-card shadow="never" class="visual-card">
+          <template #header>
+            <div class="card-header-row">
+              <span class="card-title">穴位可视化</span>
+              <span v-if="pushData" class="visual-sub">
+                {{ pushData.userName }} · {{ modeLabel(pushData.mode) }} · {{ pushData.scheme?.schemeName }}
+              </span>
+            </div>
+          </template>
+
+          <div class="body-canvas-wrap">
+            <svg viewBox="0 0 400 580" xmlns="http://www.w3.org/2000/svg" class="body-svg">
+              <defs>
+                <pattern id="sim-grid" width="20" height="20" patternUnits="userSpaceOnUse">
+                  <path d="M 20 0 L 0 0 0 20" fill="none" stroke="rgba(99,102,241,0.05)" stroke-width="1" />
+                </pattern>
+                <radialGradient id="simWash" cx="50%" cy="18%" r="75%">
+                  <stop offset="0%" stop-color="#f8fafc" />
+                  <stop offset="55%" stop-color="#f1f5f9" />
+                  <stop offset="100%" stop-color="#e8eef5" />
+                </radialGradient>
+                <linearGradient id="simBodyGrad" x1="0%" y1="0%" x2="100%" y2="100%">
+                  <stop offset="0%" stop-color="#fdf8f5" />
+                  <stop offset="35%" stop-color="#f5ebe3" />
+                  <stop offset="100%" stop-color="#e8ddd4" />
+                </linearGradient>
+                <linearGradient id="simStroke" x1="0%" y1="0%" x2="0%" y2="100%">
+                  <stop offset="0%" stop-color="#c9b8a8" />
+                  <stop offset="100%" stop-color="#a8988a" />
+                </linearGradient>
+                <filter id="simShadow" x="-25%" y="-15%" width="150%" height="130%">
+                  <feDropShadow dx="0" dy="6" stdDeviation="8" flood-color="#5c4a3d" flood-opacity="0.10" />
+                </filter>
+              </defs>
+
+              <rect width="100%" height="100%" fill="url(#simWash)" rx="14" />
+              <rect width="100%" height="100%" fill="url(#sim-grid)" rx="14" opacity="0.85" />
+
+              <line class="axis-line" x1="200" y1="12" x2="200" y2="568" stroke="rgba(148,163,184,0.4)" stroke-width="1" stroke-dasharray="4 6" />
+              <line class="axis-line" x1="12" y1="100" x2="388" y2="100" stroke="rgba(148,163,184,0.4)" stroke-width="1" stroke-dasharray="5 5" />
+
+              <!-- 人体轮廓 -->
+              <g filter="url(#simShadow)" stroke="url(#simStroke)" stroke-width="1.35" stroke-linejoin="round" stroke-linecap="round">
+                <path d="
+                  M 200 28
+                  C 186 28, 174 38, 172 56
+                  C 170 70, 174 86, 178 96
+                  C 180 102, 162 108, 138 114
+                  C 114 124, 98 136, 88 150
+                  C 80 168, 76 192, 74 220
+                  C 72 252, 68 288, 64 318
+                  C 60 340, 54 352, 62 358
+                  C 68 364, 84 356, 96 332
+                  C 108 306, 112 272, 116 238
+                  C 120 208, 124 186, 128 192
+                  C 134 204, 136 238, 138 278
+                  C 142 330, 146 374, 148 408
+                  C 152 454, 144 510, 138 548
+                  C 134 568, 130 578, 144 582
+                  C 160 586, 170 582, 176 556
+                  C 182 514, 190 452, 200 424
+                  C 210 452, 218 514, 224 556
+                  C 230 582, 240 586, 256 582
+                  C 270 578, 266 568, 262 548
+                  C 256 510, 248 454, 252 408
+                  C 254 374, 258 330, 262 278
+                  C 264 238, 266 204, 272 192
+                  C 276 186, 280 208, 284 238
+                  C 288 272, 292 306, 304 332
+                  C 316 356, 332 364, 338 358
+                  C 346 352, 340 340, 336 318
+                  C 332 288, 328 252, 326 220
+                  C 324 192, 320 168, 312 150
+                  C 304 136, 286 124, 262 114
+                  C 238 108, 220 102, 222 96
+                  C 226 86, 230 70, 228 56
+                  C 226 38, 214 28, 200 28 Z" fill="url(#simBodyGrad)" />
+                <path d="M 148 126 C 136 134, 124 158, 132 192 C 136 208, 152 212, 158 196 C 164 174, 162 142, 148 126 Z" fill="none" stroke="rgba(92,74,61,0.18)" stroke-width="1.1" />
+                <path d="M 252 126 C 264 134, 276 158, 268 192 C 264 208, 248 212, 242 196 C 236 174, 238 142, 252 126 Z" fill="none" stroke="rgba(92,74,61,0.18)" stroke-width="1.1" />
+                <path d="M 148 372 C 170 402, 186 412, 200 406 C 214 412, 230 402, 252 372" fill="none" stroke="rgba(92,74,61,0.14)" stroke-width="1.1" />
+              </g>
+
+              <line x1="200" y1="92" x2="200" y2="390" stroke="rgba(99,102,241,0.35)" stroke-width="2" stroke-dasharray="8 6" stroke-linecap="round" />
+
+              <!-- C7 大椎穴标记 -->
+              <g transform="translate(200, 100)">
+                <circle r="14" fill="none" stroke="#e85d5d" stroke-width="1" opacity="0.3">
+                  <animate attributeName="r" values="8;16;8" dur="2.5s" repeatCount="indefinite" />
+                  <animate attributeName="opacity" values="0.5;0;0.5" dur="2.5s" repeatCount="indefinite" />
+                </circle>
+                <circle r="5" fill="#fff" stroke="#e85d5d" stroke-width="2" />
+                <line x1="-11" y1="0" x2="-5" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+                <line x1="5" y1="0" x2="11" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+                <line x1="0" y1="-11" x2="0" y2="-5" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+                <line x1="0" y1="5" x2="0" y2="11" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
+                <text x="16" y="4" font-size="11" fill="#c24141" font-weight="700" font-family="system-ui, sans-serif">C7 大椎穴</text>
+              </g>
+
+              <text x="208" y="24" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">↑ 头侧</text>
+              <text x="208" y="570" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">↓ 尾侧</text>
+              <text x="14" y="92" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者左 (−X)</text>
+              <text x="386" y="92" text-anchor="end" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者右 (+X)</text>
+
+              <!-- 穴位标记 -->
+              <g v-for="(m, idx) in markers" :key="idx">
+                <circle :cx="m.x" :cy="m.y" r="12" :fill="m.color" opacity="0.18" />
+                <circle :cx="m.x" :cy="m.y" r="6" :fill="m.color" stroke="#fff" stroke-width="2" />
+                <text :x="m.x" :y="m.y + 4" text-anchor="middle" font-size="8" font-weight="800" fill="#fff" font-family="system-ui, sans-serif" style="pointer-events:none">
+                  {{ m.num }}
+                </text>
+                <text
+                  :x="m.labelX" :y="m.y + 4"
+                  :text-anchor="m.anchor"
+                  font-size="10" font-weight="700" :fill="m.color"
+                  font-family="system-ui, sans-serif"
+                  paint-order="stroke fill"
+                  stroke="rgba(255,255,255,0.92)" stroke-width="3" stroke-linejoin="round"
+                >
+                  {{ m.name }}
+                </text>
+                <!-- 步骤连接线 -->
+                <line
+                  v-if="idx > 0"
+                  :x1="markers[idx-1].x" :y1="markers[idx-1].y"
+                  :x2="m.x" :y2="m.y"
+                  stroke="rgba(16,185,129,0.25)" stroke-width="1.5" stroke-dasharray="4 4"
+                />
+              </g>
+
+              <!-- 空状态 -->
+              <g v-if="!markers.length">
+                <rect x="80" y="260" width="240" height="52" rx="26" fill="rgba(255,255,255,0.72)" stroke="rgba(148,163,184,0.35)" stroke-width="1" />
+                <text x="200" y="282" text-anchor="middle" font-size="13" fill="#475569" font-weight="600" font-family="system-ui, sans-serif">等待模拟</text>
+                <text x="200" y="300" text-anchor="middle" font-size="11" fill="#94a3b8" font-family="system-ui, sans-serif">选择用户和方案后点击「开始模拟测试」</text>
+              </g>
+            </svg>
+          </div>
+
+          <!-- 穴位图例 -->
+          <div v-if="markers.length" class="marker-legend">
+            <div class="legend-header">
+              <span class="legend-title">穴位定位结果</span>
+              <div class="legend-chips">
+                <span class="chip"><i class="dot dot-step" />步骤顺序</span>
+                <span class="chip"><i class="dot dot-coord" />坐标偏移(mm)</span>
+              </div>
+            </div>
+            <div class="legend-cards">
+              <div v-for="(m, idx) in markers" :key="idx" class="legend-item" :style="{ borderLeftColor: m.color }">
+                <div class="legend-num" :style="{ background: m.color }">{{ m.num }}</div>
+                <div class="legend-info">
+                  <div class="legend-name">{{ m.name }}</div>
+                  <div class="legend-detail">
+                    偏移: ({{ fmtNum(m.rawOffsetX) }}, {{ fmtNum(m.rawOffsetY) }}) mm ·
+                    {{ m.technique }} · {{ m.temp }}°C · {{ m.mins }}分钟
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </el-card>
+
+        <!-- 推送数据 JSON -->
+        <el-card v-if="pushData" shadow="never" class="json-card">
+          <template #header>
+            <div class="card-header-row">
+              <span class="card-title">推送数据预览(设备接收格式)</span>
+              <el-button size="small" @click="copyJson">复制 JSON</el-button>
+            </div>
+          </template>
+          <pre class="json-block">{{ pushDataJson }}</pre>
+        </el-card>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref, reactive } from 'vue'
+import { ElMessage } from 'element-plus'
+import http from '../api/http'
+
+const selectedUserId = ref(null)
+const selectedMode = ref('')
+const selectedEfficacy = ref('')
+const selectedSchemeId = ref(null)
+
+const userOptions = ref([])
+const usersLoading = ref(false)
+const allSchemes = ref([])
+const schemesLoading = ref(false)
+const simulating = ref(false)
+const pushData = ref(null)
+
+const efficacyTypes = ['驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳']
+
+const bodyForm = reactive({
+  gender: '',
+  age: '',
+  shoulderWidth: '',
+  bodyLength: '',
+  fingerCun1: '',
+  fingerCun1_5: '',
+  fingerCun3: '',
+  acupointTableStatus: '',
+})
+
+const canSimulate = computed(() => selectedUserId.value && selectedSchemeId.value)
+
+const filteredSchemes = computed(() => {
+  if (!selectedMode.value) return []
+  let list = allSchemes.value.filter((s) => s.mode === selectedMode.value)
+  if (selectedMode.value === 'PROFESSIONAL' && selectedEfficacy.value) {
+    list = list.filter((s) => s.efficacyType === selectedEfficacy.value)
+  }
+  return list
+})
+
+const totalMinutes = computed(() => {
+  const steps = pushData.value?.scheme?.steps || []
+  const totalSec = steps.reduce((sum, s) => sum + (s.duration || 0), 0)
+  return Math.round(totalSec / 60)
+})
+
+const pushDataJson = computed(() => {
+  if (!pushData.value) return ''
+  try { return JSON.stringify(pushData.value, null, 2) }
+  catch { return String(pushData.value) }
+})
+
+// SVG coordinate mapping
+const SVG_C7_X = 200
+const SVG_C7_Y = 100
+const SCALE = 0.38
+
+function bodyToSvg(offsetXmm, offsetYmm) {
+  return {
+    x: SVG_C7_X + Number(offsetXmm) * SCALE,
+    y: SVG_C7_Y - Number(offsetYmm) * SCALE,
+  }
+}
+
+const markers = computed(() => {
+  const steps = pushData.value?.scheme?.steps || []
+  return steps
+    .filter((s) => s.offsetX != null && s.offsetY != null)
+    .map((s, idx) => {
+      const { x, y } = bodyToSvg(s.offsetX, s.offsetY)
+      const techniqueColors = {
+        '温和灸': '#10b981',
+        '雀啄灸': '#f59e0b',
+        '回旋灸': '#6366f1',
+        '麻雀灸': '#ec4899',
+        '一键艾灸': '#3b82f6',
+      }
+      const color = techniqueColors[s.techniqueName] || '#10b981'
+      const onRight = x > SVG_C7_X
+      return {
+        num: s.stepOrder || idx + 1,
+        x, y, color,
+        name: s.acupointName,
+        labelX: onRight ? x - 14 : x + 14,
+        anchor: onRight ? 'end' : 'start',
+        rawOffsetX: s.offsetX,
+        rawOffsetY: s.offsetY,
+        technique: s.techniqueName,
+        temp: s.temperature,
+        mins: Math.round((s.duration || 0) / 60),
+      }
+    })
+})
+
+function modeLabel(mode) {
+  const map = { ONE_CLICK: '一键艾灸', PROFESSIONAL: '专业模式', CUSTOM: '自定义模式', EXPERT: '延年圣手' }
+  return map[mode] || mode
+}
+
+function fmtNum(v) {
+  return v != null ? Number(v).toFixed(1) : '-'
+}
+
+async function loadUsers() {
+  usersLoading.value = true
+  try {
+    const res = await http.get('/users', { params: { current: 1, size: 500 } })
+    userOptions.value = Array.isArray(res.data?.records) ? res.data.records : []
+  } catch { userOptions.value = [] }
+  finally { usersLoading.value = false }
+}
+
+async function loadSchemes() {
+  schemesLoading.value = true
+  try {
+    const res = await http.get('/schemes', { params: { current: 1, size: 500 } })
+    allSchemes.value = Array.isArray(res.data?.records)
+      ? res.data.records.filter((s) => s.status === 'published')
+      : []
+  } catch { allSchemes.value = [] }
+  finally { schemesLoading.value = false }
+}
+
+function onUserSelect(userId) {
+  pushData.value = null
+  if (!userId) {
+    Object.keys(bodyForm).forEach((k) => (bodyForm[k] = ''))
+    return
+  }
+  const u = userOptions.value.find((x) => x.id === userId)
+  if (!u) return
+  bodyForm.gender = u.gender || ''
+  bodyForm.age = u.age || ''
+  bodyForm.shoulderWidth = u.shoulderWidth ?? ''
+  bodyForm.bodyLength = u.bodyLength ?? ''
+  bodyForm.fingerCun1 = u.fingerCun1 ?? ''
+  bodyForm.fingerCun1_5 = u.fingerCun1_5 ?? ''
+  bodyForm.fingerCun3 = u.fingerCun3 ?? ''
+  bodyForm.acupointTableStatus = u.acupointTableStatus || ''
+}
+
+function onModeChange() {
+  selectedSchemeId.value = null
+  selectedEfficacy.value = ''
+  pushData.value = null
+
+  if (selectedMode.value === 'ONE_CLICK') {
+    const oneClick = allSchemes.value.find((s) => s.mode === 'ONE_CLICK')
+    if (oneClick) selectedSchemeId.value = oneClick.id
+  }
+}
+
+function onEfficacyChange() {
+  selectedSchemeId.value = null
+  const match = filteredSchemes.value[0]
+  if (match) selectedSchemeId.value = match.id
+}
+
+async function runSimulation() {
+  if (!selectedUserId.value || !selectedSchemeId.value) {
+    ElMessage.warning('请先选择用户和方案')
+    return
+  }
+
+  simulating.value = true
+  try {
+    const res = await http.get(`/users/${selectedUserId.value}/push-data/${selectedSchemeId.value}`)
+    pushData.value = res.data
+    ElMessage.success('模拟完成,查看右侧可视化结果')
+  } catch (e) {
+    ElMessage.error('模拟失败: ' + (e.message || '未知错误'))
+  } finally {
+    simulating.value = false
+  }
+}
+
+function copyJson() {
+  navigator.clipboard.writeText(pushDataJson.value).then(
+    () => ElMessage.success('已复制'),
+    () => ElMessage.error('复制失败')
+  )
+}
+
+onMounted(() => {
+  loadUsers()
+  loadSchemes()
+})
+</script>
+
+<style scoped>
+.simulation-page {
+  max-width: 1600px;
+  margin: 0 auto;
+}
+.page-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #334155;
+}
+
+.sim-layout {
+  display: flex;
+  gap: 24px;
+  align-items: flex-start;
+}
+.sim-config {
+  flex: 0 0 480px;
+  min-width: 380px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+.sim-visual {
+  flex: 1;
+  min-width: 420px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.config-card,
+.result-card,
+.visual-card,
+.json-card {
+  border: none;
+  border-radius: 14px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03);
+}
+.card-title {
+  font-weight: 600;
+  color: #334155;
+  font-size: 15px;
+}
+.card-header-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.config-form :deep(.el-form-item) {
+  margin-bottom: 14px;
+}
+.config-form :deep(.el-form-item__label) {
+  font-size: 13px;
+  color: #64748b;
+}
+
+.status-badge {
+  text-align: right;
+  margin-bottom: 4px;
+}
+
+.steps-card :deep(.el-table) {
+  font-size: 13px;
+}
+.num-cell {
+  font-family: ui-monospace, 'Cascadia Code', monospace;
+  font-size: 12px;
+}
+
+/* 人体模型 */
+.body-canvas-wrap {
+  display: flex;
+  justify-content: center;
+  padding: 8px;
+  background: linear-gradient(145deg, #fff 0%, #f4f6f9 100%);
+  border-radius: 18px;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 12px 40px -12px rgba(15, 23, 42, 0.10);
+  border: 1px solid rgba(255, 255, 255, 0.8);
+}
+.body-svg {
+  display: block;
+  width: 100%;
+  max-width: 420px;
+  border-radius: 14px;
+}
+
+/* 图例 */
+.marker-legend {
+  margin-top: 16px;
+}
+.legend-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.legend-title {
+  font-size: 14px;
+  font-weight: 700;
+  color: #1e293b;
+}
+.legend-chips {
+  display: flex;
+  gap: 8px;
+}
+.chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: #64748b;
+  padding: 3px 10px;
+  border-radius: 999px;
+  background: #f1f5f9;
+  border: 1px solid #e2e8f0;
+}
+.dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  display: inline-block;
+}
+.dot-step { background: #10b981; }
+.dot-coord { background: #6366f1; }
+
+.legend-cards {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 14px;
+  background: #fafbfc;
+  border-radius: 10px;
+  border: 1px solid #eef0f4;
+  border-left: 4px solid;
+  transition: background 0.15s;
+}
+.legend-item:hover {
+  background: #f1f5f9;
+}
+.legend-num {
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+.legend-info {
+  flex: 1;
+  min-width: 0;
+}
+.legend-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: #1e293b;
+}
+.legend-detail {
+  font-size: 11px;
+  color: #64748b;
+  margin-top: 2px;
+}
+
+/* JSON */
+.json-block {
+  background: #1e1e1e;
+  color: #d4d4d4;
+  padding: 16px;
+  border-radius: 10px;
+  overflow: auto;
+  max-height: 400px;
+  font-size: 12px;
+  line-height: 1.5;
+  font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
+}
+
+.modern-table {
+  border-radius: 8px;
+  overflow: hidden;
+}
+.modern-table :deep(th.el-table__cell) {
+  background-color: #f8fafc;
+  color: #475569;
+  font-weight: 600;
+  font-size: 12px;
+}
+
+.visual-sub {
+  font-size: 13px;
+  color: #64748b;
+  font-weight: 400;
+}
+
+@media (max-width: 1100px) {
+  .sim-layout {
+    flex-direction: column;
+  }
+  .sim-config {
+    flex: 1 1 auto;
+    min-width: 0;
+    max-width: 100%;
+  }
+}
+</style>

+ 133 - 0
openspec/aijiuyiBackend/admin-ui/src/views/UserDevicesView.vue

@@ -0,0 +1,133 @@
+<template>
+  <div>
+    <el-page-header @back="goBack" style="margin-bottom: 20px">
+      <template #content><span class="page-title">用户设备管理 - {{ userName }}</span></template>
+    </el-page-header>
+
+    <el-card shadow="never" class="section-card">
+      <div class="toolbar">
+        <el-button type="primary" @click="showBindDialog = true">添加设备</el-button>
+      </div>
+
+      <el-table :data="devices" class="modern-table" v-loading="loading" style="width: 100%">
+        <el-table-column label="设备编号" width="120">
+          <template #default="{ row }">{{ row.device?.deviceCode }}</template>
+        </el-table-column>
+        <el-table-column label="设备名称" min-width="140">
+          <template #default="{ row }">{{ row.device?.deviceName }}</template>
+        </el-table-column>
+        <el-table-column label="设备型号" width="100">
+          <template #default="{ row }">{{ row.device?.model }}</template>
+        </el-table-column>
+        <el-table-column label="绑定时间" width="170">
+          <template #default="{ row }">{{ row.binding?.boundAt }}</template>
+        </el-table-column>
+        <el-table-column label="是否主设备" width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.binding?.isPrimary === 1 ? 'success' : 'info'" size="small">
+              {{ row.binding?.isPrimary === 1 ? '是' : '否' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="在线状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.device?.status === 'online' ? 'success' : 'danger'" size="small">
+              {{ row.device?.status }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="setPrimary(row)">设为主设备</el-button>
+            <el-button link type="danger" @click="unbind(row)">解绑</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 绑定设备弹窗 -->
+    <el-dialog v-model="showBindDialog" title="添加设备" width="500px">
+      <el-form label-width="80px">
+        <el-form-item label="选择设备">
+          <el-select v-model="bindDeviceId" filterable placeholder="搜索设备" style="width: 100%">
+            <el-option v-for="d in allDevices" :key="d.id" :label="`${d.deviceCode} - ${d.deviceName}`" :value="d.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showBindDialog = false">取消</el-button>
+        <el-button type="primary" @click="bindDevice">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import http from '../api/http'
+
+const route = useRoute()
+const router = useRouter()
+const userId = route.params.userId
+const userName = ref('')
+const devices = ref([])
+const loading = ref(false)
+const showBindDialog = ref(false)
+const bindDeviceId = ref(null)
+const allDevices = ref([])
+
+async function fetchDevices() {
+  loading.value = true
+  try {
+    const res = await http.get(`/users/${userId}/devices`)
+    devices.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+async function fetchUser() {
+  const res = await http.get(`/users/${userId}`)
+  userName.value = res.data?.name || userId
+}
+
+async function fetchAllDevices() {
+  const res = await http.get('/devices', { params: { current: 1, size: 1000 } })
+  allDevices.value = res.data?.records || []
+}
+
+async function bindDevice() {
+  if (!bindDeviceId.value) return
+  await http.post(`/users/${userId}/devices/${bindDeviceId.value}/bind`)
+  ElMessage.success('绑定成功')
+  showBindDialog.value = false
+  await fetchDevices()
+}
+
+async function unbind(row) {
+  await ElMessageBox.confirm('确定解绑此设备?', '确认', { type: 'warning' })
+  await http.delete(`/users/${userId}/devices/${row.binding.deviceId}/unbind`)
+  ElMessage.success('解绑成功')
+  await fetchDevices()
+}
+
+async function setPrimary(row) {
+  await http.put(`/users/${userId}/devices/${row.binding.deviceId}/primary`)
+  ElMessage.success('已设为主设备')
+  await fetchDevices()
+}
+
+function goBack() { router.push('/crud/users') }
+
+onMounted(() => { fetchUser(); fetchDevices(); fetchAllDevices() })
+</script>
+
+<style scoped>
+.page-title { font-size: 18px; font-weight: 600; color: #334155; }
+.section-card { border: none; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.02); }
+.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
+.modern-table { border-radius: 8px; overflow: hidden; }
+.modern-table :deep(th.el-table__cell) { background-color: #f8fafc; color: #475569; font-weight: 600; }
+</style>

+ 298 - 0
openspec/aijiuyiBackend/admin-ui/src/views/resource/CrudView.vue

@@ -0,0 +1,298 @@
+<template>
+  <el-card v-if="cfg" shadow="never" class="crud-card">
+    <!-- 搜索条件 -->
+    <div v-if="cfg.searchFields" class="search-bar">
+      <template v-for="sf in cfg.searchFields" :key="sf.prop">
+        <el-input v-if="sf.type === 'input'" v-model="searchForm[sf.prop]" :placeholder="sf.label"
+          clearable style="width: 160px" @keyup.enter="doSearch" />
+        <el-select v-else-if="sf.type === 'select'" v-model="searchForm[sf.prop]" :placeholder="sf.label"
+          clearable style="width: 140px">
+          <el-option v-for="o in sf.options" :key="o" :label="o || '全部'" :value="o" />
+        </el-select>
+      </template>
+      <el-button type="primary" @click="doSearch">查询</el-button>
+      <el-button @click="resetSearch">重置</el-button>
+    </div>
+
+    <!-- 工具栏 -->
+    <div class="toolbar">
+      <el-button type="primary" @click="openCreate" class="btn-create">新增</el-button>
+      <el-button v-if="cfg.batchDeleteApiPath" type="danger" :disabled="!selected.length" @click="batchDelete">批量删除</el-button>
+      <el-button v-if="cfg.extraActions?.includes('publish')" :disabled="!selected.length" @click="batchPublish">批量发布</el-button>
+      <el-button v-if="cfg.extraActions?.includes('archive')" :disabled="!selected.length" @click="batchArchive">批量下架</el-button>
+      <el-button v-if="cfg.exportApiPath" @click="doExport">导出</el-button>
+    </div>
+
+    <!-- 数据表格 -->
+    <el-table :data="rows" class="modern-table" v-loading="loading" style="width: 100%"
+      @selection-change="onSelectionChange">
+      <el-table-column type="selection" width="50" />
+      <el-table-column v-for="col in cfg.columns" :key="col.prop" :prop="col.prop" :label="col.label"
+        :width="col.width" :min-width="col.minWidth" />
+      <el-table-column label="操作" :width="actionWidth" fixed="right">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+          <el-button v-if="cfg.extraActions?.includes('viewDetail')" link type="primary"
+            @click="viewDetail(row)">详情</el-button>
+          <el-button v-if="cfg.extraActions?.includes('generateAcupoint')" link type="success"
+            @click="generateAcupoint(row)">生成穴位</el-button>
+          <el-button v-if="cfg.extraActions?.includes('manageDevices')" link type="warning"
+            @click="manageDevices(row)">设备</el-button>
+          <el-button v-if="cfg.extraActions?.includes('manageUsers')" link type="warning"
+            @click="manageUsers(row)">用户</el-button>
+          <el-button link type="danger" @click="remove(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination class="pager" background layout="total, prev, pager, next, sizes"
+      :total="total" v-model:current-page="page.current" v-model:page-size="page.size"
+      :page-sizes="[10, 20, 50]" @current-change="fetchList" @size-change="fetchList" />
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close @closed="onDialogClose">
+      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="edit-form">
+        <el-form-item v-for="f in cfg.formFields" :key="f.prop" :label="f.label" :prop="f.prop">
+          <el-input v-if="f.type === 'input'" v-model="form[f.prop]" />
+          <el-input v-else-if="f.type === 'textarea'" v-model="form[f.prop]" type="textarea" :rows="4" />
+          <el-input-number v-else-if="f.type === 'number'" v-model="form[f.prop]" :controls="true" style="width: 100%" />
+          <el-select v-else-if="f.type === 'select'" v-model="form[f.prop]" clearable style="width: 100%">
+            <template v-if="Array.isArray(f.options) && typeof f.options[0] === 'object'">
+              <el-option v-for="o in f.options" :key="o.value" :label="o.label" :value="o.value" />
+            </template>
+            <template v-else>
+              <el-option v-for="o in f.options" :key="o" :label="o" :value="o" />
+            </template>
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="save">保存</el-button>
+      </template>
+    </el-dialog>
+  </el-card>
+  <el-empty v-else description="未知资源路径" />
+</template>
+
+<script setup>
+import { computed, reactive, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import http from '../../api/http'
+import { getResourceConfig } from '../../config/resources'
+
+const route = useRoute()
+const router = useRouter()
+const key = computed(() => route.params.resource)
+const cfg = computed(() => getResourceConfig(key.value))
+
+const rows = ref([])
+const total = ref(0)
+const loading = ref(false)
+const page = reactive({ current: 1, size: 10 })
+const selected = ref([])
+const searchForm = reactive({})
+
+const dialogVisible = ref(false)
+const dialogMode = ref('create')
+const dialogTitle = computed(() => (dialogMode.value === 'create' ? '新增' : '编辑'))
+const saving = ref(false)
+const formRef = ref()
+const form = reactive({})
+const detailBackup = ref(null)
+
+const actionWidth = computed(() => {
+  let w = 120
+  const ea = cfg.value?.extraActions || []
+  if (ea.includes('viewDetail')) w += 50
+  if (ea.includes('generateAcupoint')) w += 70
+  if (ea.includes('manageDevices') || ea.includes('manageUsers')) w += 50
+  return w
+})
+
+const formRules = computed(() => {
+  const r = {}
+  if (!cfg.value) return r
+  for (const f of cfg.value.formFields) {
+    if (f.required) {
+      r[f.prop] = [{ required: true, message: `请填写${f.label}`, trigger: 'blur' }]
+    }
+  }
+  return r
+})
+
+function emptyForm() {
+  for (const k of Object.keys(form)) delete form[k]
+  if (!cfg.value) return
+  for (const f of cfg.value.formFields) {
+    form[f.prop] = f.type === 'number' ? undefined : ''
+  }
+}
+
+function resetSearch() {
+  for (const k of Object.keys(searchForm)) searchForm[k] = ''
+  page.current = 1
+  fetchList()
+}
+
+function doSearch() {
+  page.current = 1
+  fetchList()
+}
+
+function onSelectionChange(val) {
+  selected.value = val
+}
+
+async function fetchList() {
+  if (!cfg.value) return
+  loading.value = true
+  try {
+    const hasSearch = cfg.value.searchApiPath && Object.values(searchForm).some(v => v !== '' && v !== undefined && v !== null)
+    const url = hasSearch ? cfg.value.searchApiPath : cfg.value.apiPath
+    const params = { current: page.current, size: page.size, ...searchForm }
+    const res = await http.get(url, { params })
+    const pr = res.data || {}
+    rows.value = pr.records || []
+    total.value = pr.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+function openCreate() {
+  dialogMode.value = 'create'
+  detailBackup.value = null
+  emptyForm()
+  dialogVisible.value = true
+}
+
+async function openEdit(row) {
+  dialogMode.value = 'edit'
+  const res = await http.get(`${cfg.value.apiPath}/${row.id}`)
+  const d = res.data
+  detailBackup.value = d
+  emptyForm()
+  for (const f of cfg.value.formFields) {
+    let v = d[f.prop]
+    if (f.type === 'number' && v !== null && v !== undefined && v !== '') v = Number(v)
+    form[f.prop] = v ?? (f.type === 'number' ? undefined : '')
+  }
+  dialogVisible.value = true
+}
+
+function buildPayload() {
+  const payload = {}
+  for (const f of cfg.value.formFields) {
+    let v = form[f.prop]
+    if (f.type === 'number') {
+      payload[f.prop] = v === '' || v === undefined ? null : Number(v)
+    } else {
+      payload[f.prop] = v === '' ? null : v
+    }
+  }
+  if (dialogMode.value === 'edit' && detailBackup.value) {
+    return { ...detailBackup.value, ...payload }
+  }
+  return payload
+}
+
+async function save() {
+  await formRef.value?.validate()
+  saving.value = true
+  try {
+    if (dialogMode.value === 'create') {
+      await http.post(cfg.value.apiPath, buildPayload())
+    } else {
+      await http.put(`${cfg.value.apiPath}/${detailBackup.value.id}`, buildPayload())
+    }
+    ElMessage.success('保存成功')
+    dialogVisible.value = false
+    await fetchList()
+  } finally {
+    saving.value = false
+  }
+}
+
+async function remove(row) {
+  await ElMessageBox.confirm(`确定删除 ID ${row.id}?`, '确认', { type: 'warning' })
+  await http.delete(`${cfg.value.apiPath}/${row.id}`)
+  ElMessage.success('删除成功')
+  await fetchList()
+}
+
+async function batchDelete() {
+  await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 条记录?`, '确认', { type: 'warning' })
+  await http.post(cfg.value.batchDeleteApiPath, { ids: selected.value.map(r => r.id) })
+  ElMessage.success('批量删除成功')
+  await fetchList()
+}
+
+async function batchPublish() {
+  await ElMessageBox.confirm(`确定批量发布选中的 ${selected.value.length} 条方案?`, '确认')
+  await http.post('/schemes/batch-publish', { ids: selected.value.map(r => r.id) })
+  ElMessage.success('批量发布成功')
+  await fetchList()
+}
+
+async function batchArchive() {
+  await ElMessageBox.confirm(`确定批量下架选中的 ${selected.value.length} 条方案?`, '确认')
+  await http.post('/schemes/batch-archive', { ids: selected.value.map(r => r.id) })
+  ElMessage.success('批量下架成功')
+  await fetchList()
+}
+
+function doExport() {
+  const params = new URLSearchParams(searchForm)
+  const token = localStorage.getItem('admin_token')
+  const url = `/api${cfg.value.exportApiPath}?${params.toString()}`
+  const a = document.createElement('a')
+  a.href = url
+  a.download = ''
+  a.click()
+}
+
+async function generateAcupoint(row) {
+  await ElMessageBox.confirm(`确定为用户 ${row.name} 生成穴位坐标?`, '确认')
+  await http.post(`/users/${row.id}/acupoint-coordinates`)
+  ElMessage.success('穴位坐标生成成功')
+  await fetchList()
+}
+
+function manageDevices(row) {
+  router.push({ name: 'user-devices', params: { userId: row.id } })
+}
+
+function manageUsers(row) {
+  router.push({ name: 'device-users', params: { deviceId: row.id } })
+}
+
+function viewDetail(row) {
+  router.push({ name: 'scheme-detail', params: { schemeId: row.id } })
+}
+
+function onDialogClose() {
+  detailBackup.value = null
+}
+
+watch(key, () => {
+  for (const k of Object.keys(searchForm)) searchForm[k] = ''
+  page.current = 1
+  if (cfg.value) fetchList()
+}, { immediate: true })
+</script>
+
+<style scoped>
+.crud-card { min-height: 400px; border: none; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,.02); padding: 4px; }
+.search-bar { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; padding: 0 4px; align-items: center; }
+.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; padding: 0 4px; }
+.btn-create { border-radius: 6px; padding: 8px 20px; font-weight: 500; box-shadow: 0 2px 6px rgba(16,185,129,.2); }
+.pager { margin-top: 24px; justify-content: flex-end; padding-right: 4px; }
+.edit-form { max-height: 60vh; overflow-y: auto; padding-right: 16px; }
+.modern-table { border-radius: 8px; overflow: hidden; }
+.modern-table :deep(.el-table__inner-wrapper::before) { display: none; }
+.modern-table :deep(th.el-table__cell) { background-color: #f8fafc; color: #475569; font-weight: 600; border-bottom: 1px solid #e2e8f0; padding: 12px 0; }
+.modern-table :deep(td.el-table__cell) { border-bottom: 1px solid #f1f5f9; padding: 12px 0; }
+.modern-table :deep(.el-table__row:hover > td.el-table__cell) { background-color: #f8fafc; }
+</style>

+ 17 - 0
openspec/aijiuyiBackend/admin-ui/vite.config.js

@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import mkcert from 'vite-plugin-mkcert'
+
+export default defineConfig({
+  plugins: [vue(), mkcert()],
+  server: {
+    port: 5173,
+    proxy: {
+      '/api': {
+        target: 'https://localhost:8888',
+        changeOrigin: true,
+        secure: false,
+      },
+    },
+  },
+})

+ 515 - 0
openspec/aijiuyiBackend/data.sql

@@ -0,0 +1,515 @@
+-- =============================================================================
+-- 智能穴位定位系统 - 数据库重建脚本
+-- 说明:DROP 所有旧表,全新创建 17 张表,插入种子数据。
+-- ID 策略:应用层使用雪花算法生成,种子数据使用固定小 ID(不会冲突)。
+-- =============================================================================
+
+CREATE DATABASE IF NOT EXISTS `aijiuyi_admin`
+  DEFAULT CHARACTER SET utf8mb4
+  COLLATE utf8mb4_unicode_ci;
+USE `aijiuyi_admin`;
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- -----------------------------------------------------------------------------
+-- DROP 所有旧表
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `scheme_target_user`;
+DROP TABLE IF EXISTS `moxa_session_record`;
+DROP TABLE IF EXISTS `moxa_process`;
+DROP TABLE IF EXISTS `device_hotkey_mapping`;
+DROP TABLE IF EXISTS `user_acupoint_item`;
+DROP TABLE IF EXISTS `user_acupoint_table`;
+DROP TABLE IF EXISTS `user_scheme`;
+DROP TABLE IF EXISTS `scheme_step`;
+DROP TABLE IF EXISTS `scheme`;
+DROP TABLE IF EXISTS `moxa_technique`;
+DROP TABLE IF EXISTS `acupoint`;
+DROP TABLE IF EXISTS `app_user_device`;
+DROP TABLE IF EXISTS `app_device`;
+DROP TABLE IF EXISTS `app_user_fingerprint`;
+DROP TABLE IF EXISTS `app_user`;
+DROP TABLE IF EXISTS `sys_operation_log`;
+DROP TABLE IF EXISTS `sys_admin_user`;
+
+-- 旧表清理
+DROP TABLE IF EXISTS `admin_user_role`;
+DROP TABLE IF EXISTS `admin_user`;
+DROP TABLE IF EXISTS `admin_role`;
+DROP TABLE IF EXISTS `operation_log`;
+DROP TABLE IF EXISTS `end_user`;
+DROP TABLE IF EXISTS `user_fingerprint`;
+DROP TABLE IF EXISTS `account`;
+DROP TABLE IF EXISTS `moxa_session_record`;
+DROP TABLE IF EXISTS `algorithm_version_record`;
+DROP TABLE IF EXISTS `algorithm_parameter_set`;
+DROP TABLE IF EXISTS `device_model`;
+DROP TABLE IF EXISTS `technique_combo_rule`;
+DROP TABLE IF EXISTS `technique`;
+DROP TABLE IF EXISTS `efficacy_version`;
+DROP TABLE IF EXISTS `efficacy`;
+DROP TABLE IF EXISTS `body_region`;
+DROP TABLE IF EXISTS `symptom`;
+
+-- =============================================================================
+-- 一、系统管理表
+-- =============================================================================
+
+CREATE TABLE `sys_admin_user` (
+    `id`             BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `username`       VARCHAR(64)  NOT NULL COMMENT '登录用户名',
+    `password_hash`  VARCHAR(255) NOT NULL COMMENT 'BCrypt 密码哈希',
+    `display_name`   VARCHAR(128) NULL     COMMENT '姓名',
+    `role`           VARCHAR(32)  NOT NULL DEFAULT 'OPERATOR' COMMENT '角色:SUPER_ADMIN / OPERATOR / READONLY',
+    `status`         VARCHAR(32)  NOT NULL DEFAULT 'ACTIVE'   COMMENT '状态:ACTIVE / DISABLED',
+    `last_login_at`  DATETIME     NULL     COMMENT '最后登录时间',
+    `deleted`        TINYINT      NOT NULL DEFAULT 0,
+    `create_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_username` (`username`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员表';
+
+CREATE TABLE `sys_operation_log` (
+    `id`             BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `operator_id`    BIGINT       NULL     COMMENT '操作人 ID',
+    `operator_name`  VARCHAR(64)  NULL     COMMENT '操作人姓名',
+    `action_type`    VARCHAR(32)  NOT NULL COMMENT '操作类型:CREATE / UPDATE / DELETE / IMPORT / EXPORT',
+    `module`         VARCHAR(64)  NOT NULL COMMENT '操作模块:USER / ACUPOINT / SCHEME / DEVICE / SYSTEM',
+    `content`        VARCHAR(512) NULL     COMMENT '操作内容',
+    `ip`             VARCHAR(64)  NULL     COMMENT '客户端 IP',
+    `create_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_operator` (`operator_id`),
+    KEY `idx_time` (`create_time`),
+    KEY `idx_module` (`module`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作审计日志表';
+
+-- =============================================================================
+-- 二、用户管理表
+-- =============================================================================
+
+CREATE TABLE `app_user` (
+    `id`                     BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `name`                   VARCHAR(128) NOT NULL COMMENT '用户姓名',
+    `gender`                 VARCHAR(16)  NOT NULL COMMENT '性别:male / female',
+    `age`                    INT          NOT NULL COMMENT '年龄',
+    `phone`                  VARCHAR(32)  NOT NULL COMMENT '手机号(唯一)',
+    `shoulder_width`         DECIMAL(6,2) NOT NULL COMMENT '肩宽(cm)',
+    `body_length`            DECIMAL(6,2) NOT NULL COMMENT '身长/坐高(cm)',
+    `finger_cun1`            DECIMAL(6,2) NULL     COMMENT '一寸(cm),选填',
+    `finger_cun1_5`          DECIMAL(6,2) NULL     COMMENT '1.5寸(cm),选填',
+    `finger_cun3`            DECIMAL(6,2) NULL     COMMENT '三寸(cm),选填',
+    `acupoint_table_status`  VARCHAR(16)  NOT NULL DEFAULT 'NOT_GENERATED' COMMENT '穴位表状态:GENERATED / NOT_GENERATED',
+    `deleted`                TINYINT      NOT NULL DEFAULT 0,
+    `create_time`            DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`            DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_phone` (`phone`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='APP 用户表';
+
+CREATE TABLE `app_user_fingerprint` (
+    `id`              BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `user_id`         BIGINT       NOT NULL COMMENT '用户 ID',
+    `fingerprint_id`  VARCHAR(128) NOT NULL COMMENT '设备侧指纹标识',
+    `deleted`         TINYINT      NOT NULL DEFAULT 0,
+    `create_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户指纹表';
+
+-- =============================================================================
+-- 三、设备管理表
+-- =============================================================================
+
+CREATE TABLE `app_device` (
+    `id`                BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `device_code`       VARCHAR(64)  NOT NULL COMMENT '设备编号(如 AJY001)',
+    `device_name`       VARCHAR(128) NOT NULL COMMENT '设备名称',
+    `model`             VARCHAR(64)  NOT NULL COMMENT '设备型号',
+    `serial_number`     VARCHAR(128) NOT NULL COMMENT '设备序列号(唯一标识)',
+    `firmware_version`  VARCHAR(64)  NULL     COMMENT '固件版本',
+    `status`            VARCHAR(16)  NOT NULL DEFAULT 'offline' COMMENT '在线状态:online / offline',
+    `last_online_at`    DATETIME     NULL     COMMENT '最后在线时间',
+    `remark`            VARCHAR(512) NULL     COMMENT '备注',
+    `deleted`           TINYINT      NOT NULL DEFAULT 0,
+    `create_time`       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_device_code` (`device_code`),
+    UNIQUE KEY `uk_serial_number` (`serial_number`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='设备实例表';
+
+CREATE TABLE `app_user_device` (
+    `id`          BIGINT   NOT NULL COMMENT '主键(雪花算法)',
+    `user_id`     BIGINT   NOT NULL COMMENT '用户 ID',
+    `device_id`   BIGINT   NOT NULL COMMENT '设备 ID',
+    `is_primary`  TINYINT  NOT NULL DEFAULT 0 COMMENT '是否主设备:1 是 0 否',
+    `bound_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
+    `deleted`     TINYINT  NOT NULL DEFAULT 0,
+    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_device` (`user_id`, `device_id`),
+    KEY `idx_device` (`device_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户-设备绑定关系表(多对多)';
+
+-- =============================================================================
+-- 四、穴位管理表
+-- =============================================================================
+
+CREATE TABLE `acupoint` (
+    `id`              BIGINT        NOT NULL COMMENT '主键(雪花算法)',
+    `code`            VARCHAR(64)   NOT NULL COMMENT '穴位编码(如 dazhui)',
+    `name`            VARCHAR(128)  NOT NULL COMMENT '穴位名称(如 大椎穴)',
+    `meridian`        VARCHAR(128)  NULL     COMMENT '所属经络',
+    `efficacy`        VARCHAR(512)  NULL     COMMENT '功效(逗号分隔)',
+    `positioning`     VARCHAR(16)   NOT NULL DEFAULT 'relative' COMMENT '定位方式:relative / absolute',
+    `offset_cun`      DECIMAL(6,2)  NULL     COMMENT '相对大椎穴纵向偏移(寸数)',
+    `cun_type`        VARCHAR(16)   NULL     COMMENT '指寸类型:cun1 / cun1_5 / cun3',
+    `side`            VARCHAR(16)   NOT NULL DEFAULT 'center'  COMMENT '侧别:center / both',
+    `lateral_offset`  DECIMAL(6,2)  NULL     COMMENT '横向偏移(寸数),双侧穴位使用',
+    `absolute_x`      DECIMAL(12,4) NULL     COMMENT '绝对坐标 X(mm)',
+    `absolute_y`      DECIMAL(12,4) NULL     COMMENT '绝对坐标 Y(mm)',
+    `absolute_z`      DECIMAL(12,4) NULL     COMMENT '绝对坐标 Z(mm)',
+    `target_gender`   VARCHAR(16)   NOT NULL DEFAULT 'all' COMMENT '适用性别:all / male / female',
+    `status`          VARCHAR(16)   NOT NULL DEFAULT 'active'  COMMENT '状态:active / pending',
+    `source`          VARCHAR(16)   NOT NULL DEFAULT 'preset'  COMMENT '数据来源:preset / expert',
+    `deleted`         TINYINT       NOT NULL DEFAULT 0,
+    `create_time`     DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`     DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_code` (`code`),
+    KEY `idx_status` (`status`),
+    KEY `idx_meridian` (`meridian`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='穴位基础库表';
+
+CREATE TABLE `moxa_technique` (
+    `id`                  BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `code`                VARCHAR(64)  NOT NULL COMMENT '手法编码(如 gentle)',
+    `name`                VARCHAR(128) NOT NULL COMMENT '手法名称(如 温和灸)',
+    `is_default`          TINYINT      NOT NULL DEFAULT 0 COMMENT '是否默认手法:1 是 0 否',
+    `description`         VARCHAR(512) NULL     COMMENT '手法描述',
+    `movement_pattern`    VARCHAR(32)  NOT NULL COMMENT '运动模式:fixed / vertical / circular / point / oneclick',
+    `temp_min`            DECIMAL(4,1) NOT NULL DEFAULT 35.0 COMMENT '温度范围最小值(°C)',
+    `temp_max`            DECIMAL(4,1) NOT NULL DEFAULT 50.0 COMMENT '温度范围最大值(°C)',
+    `duration_min`        INT          NOT NULL DEFAULT 60   COMMENT '时间范围最小值(秒)',
+    `duration_max`        INT          NOT NULL DEFAULT 1800 COMMENT '时间范围最大值(秒)',
+    `movement_amplitude`  DECIMAL(6,2) NULL     COMMENT '运动幅度(mm)',
+    `movement_speed`      DECIMAL(6,2) NULL     COMMENT '运动速度(mm/s)',
+    `status`              VARCHAR(16)  NOT NULL DEFAULT 'active' COMMENT '状态:active / disabled',
+    `remark`              VARCHAR(512) NULL     COMMENT '备注',
+    `deleted`             TINYINT      NOT NULL DEFAULT 0,
+    `create_time`         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_code` (`code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='艾灸手法表';
+
+-- =============================================================================
+-- 五、方案管理表
+-- =============================================================================
+
+CREATE TABLE `scheme` (
+    `id`             BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `scheme_name`    VARCHAR(128) NOT NULL COMMENT '方案名称',
+    `mode`           VARCHAR(32)  NOT NULL COMMENT '模式:ONE_CLICK / PROFESSIONAL / CUSTOM / EXPERT',
+    `efficacy_type`  VARCHAR(32)  NULL     COMMENT '功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳',
+    `creator_type`   VARCHAR(16)  NOT NULL COMMENT '创作人类型:admin / user / expert',
+    `creator_id`     VARCHAR(64)  NULL     COMMENT '创作人 ID',
+    `creator_name`   VARCHAR(128) NULL     COMMENT '创作人显示名称',
+    `symptoms`       VARCHAR(512) NULL     COMMENT '适用症状(逗号分隔)',
+    `target_gender`  VARCHAR(16)  NOT NULL DEFAULT 'all' COMMENT '适用性别:all / male / female',
+    `age_min`        INT          NOT NULL DEFAULT 1   COMMENT '适用年龄下限',
+    `age_max`        INT          NOT NULL DEFAULT 120 COMMENT '适用年龄上限',
+    `description`    VARCHAR(512) NULL     COMMENT '方案描述',
+    `is_default`     TINYINT      NOT NULL DEFAULT 0 COMMENT '是否默认方案',
+    `expert_id`      VARCHAR(64)  NULL     COMMENT '专家 ID(延年圣手模式)',
+    `expert_name`    VARCHAR(128) NULL     COMMENT '专家名称(延年圣手模式)',
+    `publish_type`   VARCHAR(16)  NULL     COMMENT '发布类型:public / private(延年圣手模式)',
+    `process_id`     BIGINT       NULL     COMMENT '关联工艺语言 ID',
+    `status`         VARCHAR(16)  NOT NULL DEFAULT 'draft' COMMENT '状态:draft / published / archived',
+    `deleted`        TINYINT      NOT NULL DEFAULT 0,
+    `create_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_mode` (`mode`),
+    KEY `idx_status` (`status`),
+    KEY `idx_efficacy` (`efficacy_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='艾灸方案表(四种模式统一存储)';
+
+CREATE TABLE `scheme_step` (
+    `id`              BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `scheme_id`       BIGINT       NOT NULL COMMENT '方案 ID',
+    `step_order`      INT          NOT NULL COMMENT '步骤序号',
+    `acupoint_id`     BIGINT       NOT NULL COMMENT '穴位 ID',
+    `acupoint_name`   VARCHAR(128) NOT NULL COMMENT '穴位名称(冗余)',
+    `side`            VARCHAR(16)  NOT NULL DEFAULT 'center' COMMENT '侧别:center / both / left / right',
+    `temperature`     DECIMAL(4,1) NOT NULL COMMENT '温度(°C)',
+    `duration`        INT          NOT NULL COMMENT '时间(秒)',
+    `technique_id`    BIGINT       NOT NULL COMMENT '手法 ID',
+    `technique_name`  VARCHAR(128) NOT NULL COMMENT '手法名称(冗余)',
+    `remark`          VARCHAR(256) NULL     COMMENT '备注',
+    `deleted`         TINYINT      NOT NULL DEFAULT 0,
+    `create_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_scheme` (`scheme_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='方案治疗步骤表';
+
+CREATE TABLE `user_scheme` (
+    `id`          BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `user_id`     BIGINT       NOT NULL COMMENT '用户 ID',
+    `scheme_id`   BIGINT       NOT NULL COMMENT '方案 ID',
+    `mode`        VARCHAR(32)  NOT NULL COMMENT '模式',
+    `selected_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选择时间',
+    `deleted`     TINYINT      NOT NULL DEFAULT 0,
+    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_user` (`user_id`),
+    KEY `idx_scheme` (`scheme_id`),
+    KEY `idx_user_mode` (`user_id`, `mode`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户-方案关联表';
+
+CREATE TABLE `scheme_target_user` (
+    `id`          BIGINT   NOT NULL COMMENT '主键(雪花算法)',
+    `scheme_id`   BIGINT   NOT NULL COMMENT '方案 ID',
+    `user_id`     BIGINT   NOT NULL COMMENT '目标用户 ID',
+    `deleted`     TINYINT  NOT NULL DEFAULT 0,
+    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_scheme` (`scheme_id`),
+    KEY `idx_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='方案指定用户表(延年圣手私有发布)';
+
+-- =============================================================================
+-- 六、穴位坐标数据表(用户穴位对照表)
+-- =============================================================================
+
+CREATE TABLE `user_acupoint_table` (
+    `id`              BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `user_id`         BIGINT       NOT NULL COMMENT '用户 ID',
+    `shoulder_width`  DECIMAL(6,2) NOT NULL COMMENT '生成时肩宽(cm)',
+    `body_length`     DECIMAL(6,2) NOT NULL COMMENT '生成时身长(cm)',
+    `finger_cun1`     DECIMAL(6,2) NOT NULL COMMENT '使用的一寸值(cm)',
+    `finger_cun1_5`   DECIMAL(6,2) NOT NULL COMMENT '使用的 1.5 寸值(cm)',
+    `finger_cun3`     DECIMAL(6,2) NOT NULL COMMENT '使用的三寸值(cm)',
+    `is_estimated`    TINYINT      NOT NULL DEFAULT 0 COMMENT '指寸是否为估算:1 是 0 否',
+    `confidence`      DECIMAL(5,2) NULL     COMMENT '置信度(%)',
+    `precision_cm`    DECIMAL(4,1) NULL     COMMENT '预估精度(±cm)',
+    `deleted`         TINYINT      NOT NULL DEFAULT 0,
+    `create_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户穴位对照表';
+
+CREATE TABLE `user_acupoint_item` (
+    `id`              BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `table_id`        BIGINT       NOT NULL COMMENT '对照表 ID',
+    `user_id`         BIGINT       NOT NULL COMMENT '用户 ID',
+    `acupoint_id`     BIGINT       NOT NULL COMMENT '穴位 ID',
+    `acupoint_code`   VARCHAR(64)  NOT NULL COMMENT '穴位编码',
+    `acupoint_name`   VARCHAR(128) NOT NULL COMMENT '穴位名称',
+    `side`            VARCHAR(16)  NOT NULL DEFAULT 'center' COMMENT '侧别:center / left / right',
+    `offset_x`        DECIMAL(8,2) NOT NULL COMMENT '相对大椎穴横向偏移(cm)',
+    `offset_y`        DECIMAL(8,2) NOT NULL COMMENT '相对大椎穴纵向偏移(cm)',
+    `offset_in_cun`   DECIMAL(6,2) NOT NULL COMMENT '偏移寸数',
+    `cun_type`        VARCHAR(16)  NOT NULL COMMENT '指寸类型:cun1 / cun1_5 / cun3',
+    `deleted`         TINYINT      NOT NULL DEFAULT 0,
+    `create_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_table` (`table_id`),
+    KEY `idx_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户穴位坐标明细表';
+
+-- =============================================================================
+-- 七、设备同步表(延年圣手快捷键)
+-- =============================================================================
+
+CREATE TABLE `device_hotkey_mapping` (
+    `id`           BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `user_id`      BIGINT       NOT NULL COMMENT '用户 ID',
+    `device_id`    BIGINT       NOT NULL COMMENT '设备 ID',
+    `slot`         INT          NOT NULL COMMENT '快捷键槽位(1-8)',
+    `scheme_id`    BIGINT       NOT NULL COMMENT '方案 ID',
+    `scheme_name`  VARCHAR(128) NOT NULL COMMENT '方案名称(冗余)',
+    `process_id`   BIGINT       NULL     COMMENT '工艺语言 ID',
+    `synced_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间',
+    `deleted`      TINYINT      NOT NULL DEFAULT 0,
+    `create_time`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_device_slot` (`user_id`, `device_id`, `slot`),
+    KEY `idx_device` (`device_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='设备快捷键映射表(延年圣手 1-8)';
+
+-- =============================================================================
+-- 八、工艺语言表
+-- =============================================================================
+
+CREATE TABLE `moxa_process` (
+    `id`           BIGINT   NOT NULL COMMENT '主键(雪花算法)',
+    `scheme_id`    BIGINT   NOT NULL COMMENT '方案 ID',
+    `process_data` JSON     NOT NULL COMMENT '工艺语言数据(JSON)',
+    `version`      INT      NOT NULL DEFAULT 1 COMMENT '版本号',
+    `deleted`      TINYINT  NOT NULL DEFAULT 0,
+    `create_time`  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time`  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_scheme` (`scheme_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='艾灸工艺语言表';
+
+-- =============================================================================
+-- 九、艾灸记录表
+-- =============================================================================
+
+CREATE TABLE `moxa_session_record` (
+    `id`          BIGINT       NOT NULL COMMENT '主键(雪花算法)',
+    `user_id`     BIGINT       NOT NULL COMMENT '用户 ID',
+    `device_id`   BIGINT       NULL     COMMENT '设备 ID',
+    `scheme_id`   BIGINT       NULL     COMMENT '方案 ID',
+    `started_at`  DATETIME     NULL     COMMENT '开始时间',
+    `ended_at`    DATETIME     NULL     COMMENT '结束时间',
+    `duration`    INT          NULL     COMMENT '时长(秒)',
+    `rating`      INT          NULL     COMMENT '评分(1-5)',
+    `remark`      VARCHAR(512) NULL     COMMENT '备注',
+    `deleted`     TINYINT      NOT NULL DEFAULT 0,
+    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_user` (`user_id`),
+    KEY `idx_scheme` (`scheme_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='艾灸会话记录表';
+
+-- =============================================================================
+-- 种子数据
+-- =============================================================================
+
+-- --------------- 1. 管理员账号 ---------------
+-- 默认管理员密码: admin
+INSERT INTO `sys_admin_user` (`id`, `username`, `password_hash`, `display_name`, `role`, `status`) VALUES
+(1, 'admin', '$2b$10$yW9CwM2u/vqz4CGZsQXPyeiKYtsu4PbAj.vKstKoY4hbFT6I0LiNm', '系统管理员', 'SUPER_ADMIN', 'ACTIVE');
+
+-- --------------- 2. 基础穴位库(12 个) ---------------
+INSERT INTO `acupoint` (`id`, `code`, `name`, `meridian`, `efficacy`, `positioning`, `offset_cun`, `cun_type`, `side`, `lateral_offset`, `target_gender`, `status`, `source`) VALUES
+(101, 'dazhui',      '大椎穴',   '督脉',           '驱寒,祛湿,解表',   'relative', 0,    'cun1', 'center', NULL, 'all', 'active', 'preset'),
+(102, 'shenzhu',     '身柱穴',   '督脉',           '止咳平喘,安神',    'relative', 3,    'cun1', 'center', NULL, 'all', 'active', 'preset'),
+(103, 'zhiyang',     '至阳穴',   '督脉',           '宽胸理气',         'relative', 7,    'cun1', 'center', NULL, 'all', 'active', 'preset'),
+(104, 'mingmen',     '命门穴',   '督脉',           '补肾壮阳',         'relative', 14,   'cun1', 'center', NULL, 'all', 'active', 'preset'),
+(105, 'yaoyangquan', '腰阳关穴', '督脉',           '强腰健肾',         'relative', 17,   'cun1', 'center', NULL, 'all', 'active', 'preset'),
+(106, 'fengmen',     '风门穴',   '足太阳膀胱经',   '祛风解表',         'relative', 1.5,  'cun1', 'both',   1.5,  'all', 'active', 'preset'),
+(107, 'bingfeng',    '秉风穴',   '手太阳小肠经',   '祛风散寒',         'relative', 3,    'cun1', 'both',   3,    'all', 'active', 'preset'),
+(108, 'feishu',      '肺俞穴',   '足太阳膀胱经',   '化痰止咳',         'relative', 3,    'cun1', 'both',   1.5,  'all', 'active', 'preset'),
+(109, 'geshu',       '膈俞穴',   '足太阳膀胱经',   '活血化瘀',         'relative', 7,    'cun1', 'both',   1.5,  'all', 'active', 'preset'),
+(110, 'pishu',       '脾俞穴',   '足太阳膀胱经',   '健脾养血',         'relative', 11,   'cun1', 'both',   1.5,  'all', 'active', 'preset'),
+(111, 'shenshu',     '肾俞穴',   '足太阳膀胱经',   '补肾益气',         'relative', 14,   'cun1', 'both',   1.5,  'all', 'active', 'preset'),
+(112, 'baliao',      '八髎穴',   '足太阳膀胱经',   '调理下焦',         'relative', 16,   'cun1', 'center', NULL, 'all', 'active', 'preset');
+
+-- --------------- 3. 艾灸手法(5 种) ---------------
+INSERT INTO `moxa_technique` (`id`, `code`, `name`, `is_default`, `description`, `movement_pattern`, `temp_min`, `temp_max`, `duration_min`, `duration_max`, `status`) VALUES
+(201, 'oneclick', '一键艾灸', 1, '系统默认工艺,督脉5穴温和灸',       'oneclick', 42.0, 45.0, 1800, 1800, 'active'),
+(202, 'gentle',   '温和灸',   0, '固定不动,常规艾灸,温补',           'fixed',    35.0, 50.0, 60,   1800, 'active'),
+(203, 'pecking',  '雀啄灸',   0, '上下移动,强刺激,祛邪',             'vertical', 35.0, 50.0, 60,   1800, 'active'),
+(204, 'circular', '回旋灸',   0, '圆周运动,温经散寒',                 'circular', 35.0, 50.0, 60,   1800, 'active'),
+(205, 'sparrow',  '麻雀灸',   0, '点状移动,局部强刺激',               'point',    35.0, 50.0, 60,   1800, 'active');
+
+-- --------------- 4. 一键艾灸默认方案 ---------------
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `age_min`, `age_max`, `description`, `is_default`, `status`) VALUES
+(301, '一键艾灸(默认)', 'ONE_CLICK', NULL, 'admin', '1', 'ADMIN', NULL, 'all', 1, 120, '系统默认一键艾灸方案,督脉5穴温和灸,总时长约30分钟', 1, 'published');
+
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`, `remark`) VALUES
+(401, 301, 1, 101, '大椎穴',   'center', 42.0, 300, 202, '温和灸', '基准穴位'),
+(402, 301, 2, 102, '身柱穴',   'center', 43.0, 300, 202, '温和灸', NULL),
+(403, 301, 3, 103, '至阳穴',   'center', 44.0, 360, 202, '温和灸', NULL),
+(404, 301, 4, 104, '命门穴',   'center', 44.0, 420, 202, '温和灸', NULL),
+(405, 301, 5, 105, '腰阳关穴', 'center', 45.0, 420, 202, '温和灸', NULL);
+
+-- --------------- 5. 8 种专业模式默认方案 ---------------
+
+-- 5.1 驱寒模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(302, '驱寒模式', 'PROFESSIONAL', '驱寒', 'admin', '1', 'ADMIN', '怕冷,手脚冰凉', 'all', '温阳散寒方案,针对怕冷、手脚冰凉等寒证', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(411, 302, 1, 101, '大椎穴', 'center', 46.0, 540, 202, '温和灸'),
+(412, 302, 2, 104, '命门穴', 'center', 47.0, 600, 202, '温和灸');
+
+-- 5.2 祛湿模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(303, '祛湿模式', 'PROFESSIONAL', '祛湿', 'admin', '1', 'ADMIN', '身体困重,大便黏腻', 'all', '健脾祛湿方案,针对身体困重、大便黏腻等湿证', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(421, 303, 1, 101, '大椎穴', 'center', 43.0, 360, 202, '温和灸'),
+(422, 303, 2, 110, '脾俞穴', 'both',   44.0, 420, 202, '温和灸'),
+(423, 303, 3, 111, '肾俞穴', 'both',   44.0, 420, 202, '温和灸');
+
+-- 5.3 祛风模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(304, '祛风模式', 'PROFESSIONAL', '祛风', 'admin', '1', 'ADMIN', '感冒,头痛', 'all', '祛风解表方案,针对感冒、头痛等风证', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(431, 304, 1, 101, '大椎穴', 'center', 41.0, 330, 203, '雀啄灸'),
+(432, 304, 2, 106, '风门穴', 'both',   41.0, 330, 203, '雀啄灸'),
+(433, 304, 3, 107, '秉风穴', 'both',   41.0, 330, 203, '雀啄灸');
+
+-- 5.4 化瘀模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(305, '化瘀模式', 'PROFESSIONAL', '化瘀', 'admin', '1', 'ADMIN', '疼痛,淤青', 'all', '活血化瘀方案,针对疼痛、淤青等血瘀证', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(441, 305, 1, 101, '大椎穴', 'center', 44.0, 420, 204, '回旋灸'),
+(442, 305, 2, 109, '膈俞穴', 'both',   44.0, 420, 204, '回旋灸'),
+(443, 305, 3, 112, '八髎穴', 'center', 44.0, 420, 204, '回旋灸');
+
+-- 5.5 活血模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(306, '活血模式', 'PROFESSIONAL', '活血', 'admin', '1', 'ADMIN', '血液循环不畅', 'all', '活血通络方案,针对血液循环不畅', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(451, 306, 1, 101, '大椎穴', 'center', 43.0, 420, 202, '温和灸'),
+(452, 306, 2, 109, '膈俞穴', 'both',   44.0, 420, 202, '温和灸'),
+(453, 306, 3, 104, '命门穴', 'center', 44.0, 420, 202, '温和灸');
+
+-- 5.6 化痰模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(307, '化痰模式', 'PROFESSIONAL', '化痰', 'admin', '1', 'ADMIN', '咳嗽,痰多', 'all', '化痰止咳方案,针对咳嗽、痰多', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(461, 307, 1, 101, '大椎穴', 'center', 41.0, 360, 202, '温和灸'),
+(462, 307, 2, 108, '肺俞穴', 'both',   42.0, 360, 202, '温和灸'),
+(463, 307, 3, 110, '脾俞穴', 'both',   42.0, 360, 202, '温和灸');
+
+-- 5.7 养颜模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(308, '养颜模式', 'PROFESSIONAL', '养颜', 'admin', '1', 'ADMIN', '皮肤暗沉', 'all', '美容养颜方案,针对皮肤暗沉', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(471, 308, 1, 101, '大椎穴', 'center', 41.0, 330, 202, '温和灸'),
+(472, 308, 2, 108, '肺俞穴', 'both',   41.0, 330, 202, '温和灸'),
+(473, 308, 3, 110, '脾俞穴', 'both',   41.0, 330, 202, '温和灸'),
+(474, 308, 4, 111, '肾俞穴', 'both',   41.0, 330, 202, '温和灸');
+
+-- 5.8 扶阳模式
+INSERT INTO `scheme` (`id`, `scheme_name`, `mode`, `efficacy_type`, `creator_type`, `creator_id`, `creator_name`, `symptoms`, `target_gender`, `description`, `is_default`, `status`) VALUES
+(309, '扶阳模式', 'PROFESSIONAL', '扶阳', 'admin', '1', 'ADMIN', '腰膝酸软', 'all', '温补肾阳方案,针对腰膝酸软', 1, 'published');
+INSERT INTO `scheme_step` (`id`, `scheme_id`, `step_order`, `acupoint_id`, `acupoint_name`, `side`, `temperature`, `duration`, `technique_id`, `technique_name`) VALUES
+(481, 309, 1, 101, '大椎穴', 'center', 46.0, 540, 202, '温和灸'),
+(482, 309, 2, 104, '命门穴', 'center', 47.0, 540, 202, '温和灸'),
+(483, 309, 3, 103, '至阳穴', 'center', 46.0, 540, 202, '温和灸');
+
+-- --------------- 6. 模拟用户数据 ---------------
+INSERT INTO `app_user` (`id`, `name`, `gender`, `age`, `phone`, `shoulder_width`, `body_length`, `finger_cun1`, `finger_cun1_5`, `finger_cun3`, `acupoint_table_status`) VALUES
+(501, '张三', 'male',   35, '13800001234', 42.00, 85.00, 2.1, 3.2, 6.3, 'GENERATED'),
+(502, '李四', 'female', 33, '13900005678', 36.00, 78.00, NULL, NULL, NULL, 'NOT_GENERATED'),
+(503, '王五', 'male',   62, '13700009012', 40.00, 82.00, NULL, NULL, NULL, 'NOT_GENERATED');
+
+-- --------------- 7. 模拟设备数据 ---------------
+INSERT INTO `app_device` (`id`, `device_code`, `device_name`, `model`, `serial_number`, `firmware_version`, `status`) VALUES
+(601, 'AJY001', '艾灸椅-001', 'AJY-2026', 'SN20260001', 'v1.2.0', 'online'),
+(602, 'AJY002', '艾灸椅-002', 'AJY-2026', 'SN20260002', 'v1.1.0', 'offline');
+
+-- --------------- 8. 用户-设备绑定 ---------------
+INSERT INTO `app_user_device` (`id`, `user_id`, `device_id`, `is_primary`, `bound_at`) VALUES
+(701, 501, 601, 1, '2026-03-15 10:00:00'),
+(702, 501, 602, 0, '2026-03-18 14:00:00'),
+(703, 502, 601, 0, '2026-03-18 14:00:00');
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 111 - 0
openspec/aijiuyiBackend/pom.xml

@@ -0,0 +1,111 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.aijiuyi</groupId>
+    <artifactId>aijiuyi-admin</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+    <name>aijiuyi-admin</name>
+    <description>PC admin API for acupoint / moxibustion backend</description>
+
+    <properties>
+        <java.version>1.8</java.version>
+        <mybatis-plus.version>3.5.5</mybatis-plus.version>
+        <springdoc.version>1.7.0</springdoc.version>
+        <jjwt.version>0.11.5</jjwt.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-boot-starter</artifactId>
+            <version>${mybatis-plus.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springdoc</groupId>
+            <artifactId>springdoc-openapi-ui</artifactId>
+            <version>${springdoc.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-api</artifactId>
+            <version>${jjwt.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-impl</artifactId>
+            <version>${jjwt.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-jackson</artifactId>
+            <version>${jjwt.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-ooxml</artifactId>
+            <version>5.2.5</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 211 - 0
openspec/aijiuyiBackend/scripts/generate_seed_sql.py

@@ -0,0 +1,211 @@
+# -*- coding: utf-8 -*-
+"""
+从《智能穴位识别数据模型》xlsx 生成种子 SQL,并写回 V1__init.sql 中
+「-- >>> GENERATED_SEED_START」与「-- <<< GENERATED_SEED_END」之间的内容。
+
+读取工作表:
+  - 第 1 张:穴位数据模型(筛选「艾灸椅产品穴位」为「是」,且身体部位∈{背部,颈部},
+    部位分区∈{背部,骶区,颈后部,腰部};列名支持「身体部位」或「身体补位」)
+  - 第 2 张:症状模型 -> symptom
+  - 第 3 张:部位定位模型 -> body_region
+
+并追加:admin_role、admin_user(默认 admin/admin 的 BCrypt)、admin_user_role、device_model。
+需在环境中安装:pip install openpyxl
+"""
+import glob
+import os
+
+try:
+    import openpyxl
+except ImportError:
+    raise SystemExit("pip install openpyxl")
+
+BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+os.chdir(BASE)
+
+V1_SQL = os.path.join(BASE, "src", "main", "resources", "db", "migration", "V1__init.sql")
+START_TAG = "-- >>> GENERATED_SEED_START"
+END_TAG = "-- <<< GENERATED_SEED_END"
+
+
+def esc(s):
+    if s is None:
+        return "NULL"
+    s = str(s).replace("\\", "\\\\").replace("'", "''").replace("\r", " ").replace("\n", " ")
+    return "'" + s + "'"
+
+
+def main():
+    paths = glob.glob(os.path.join(BASE, "*数据模型*.xlsx"))
+    if not paths:
+        paths = glob.glob(os.path.join(BASE, "*.xlsx"))
+        paths = [p for p in paths if "数据模型" in os.path.basename(p) or "模型" in os.path.basename(p)]
+    if not paths:
+        raise SystemExit("No data model xlsx found")
+    path = paths[0]
+    wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
+
+    lines = []
+    lines.append("SET FOREIGN_KEY_CHECKS = 0;")
+
+    # Symptoms sheet 2
+    ws_sym = wb[wb.sheetnames[1]]
+    rows_sym = list(ws_sym.iter_rows(values_only=True))
+    lines.append("")
+    lines.append("-- 症状字典(Excel「症状模型」)")
+    lines.append("INSERT IGNORE INTO symptom (code, name) VALUES ")
+    vals = []
+    for row in rows_sym[1:]:
+        if not row or row[0] is None:
+            continue
+        code = esc(str(row[0]).strip())
+        name = esc(str(row[1]).strip() if len(row) > 1 and row[1] is not None else "")
+        vals.append("({0}, {1})".format(code, name))
+    lines.append(",\n".join(vals) + ";")
+
+    # Body regions sheet 3
+    ws_br = wb[wb.sheetnames[2]]
+    rows_br = list(ws_br.iter_rows(values_only=True))
+    lines.append("")
+    lines.append("-- 身体部位与分区(Excel「部位定位模型」)")
+    lines.append("INSERT IGNORE INTO body_region (body_part, region_partition) VALUES ")
+    vals = []
+    for row in rows_br[1:]:
+        if row is None:
+            continue
+        bp = row[0]
+        rp = row[1]
+        if bp is None and rp is None:
+            continue
+        vals.append("({0}, {1})".format(esc(bp), esc(rp)))
+    lines.append(",\n".join(vals) + ";")
+
+    # Acupoints sheet 1
+    ws = wb[wb.sheetnames[0]]
+    header = next(ws.iter_rows(min_row=1, max_row=1, values_only=True))
+    h = list(header)
+
+    def col(*names):
+        for n in names:
+            if n in h:
+                return h.index(n)
+        raise SystemExit("Excel 表头缺少列: " + " / ".join(names))
+
+    idx_chair = col("艾灸椅产品穴位")
+    idx_body = col("身体部位", "身体补位")
+    cols = {
+        "serial_no": h.index("序号"),
+        "code": h.index("穴位编号"),
+        "name": h.index("穴位名称"),
+        "side_type": h.index("单/双穴"),
+        "meridian": h.index("所属经络"),
+        "body_region": idx_body,
+        "region_partition": h.index("部位分区"),
+        "location_text": h.index("穴位定位"),
+        "relative_position_text": h.index("相对位置"),
+        "indications": h.index("主管疾病"),
+        "symptom_coverage": h.index("产品症状覆盖"),
+        "acupoint_method": h.index("取穴"),
+        "remark": len(h) - 1,
+    }
+
+    allowed_body = {"背部", "颈部"}
+    allowed_partition = {"背部", "骶区", "颈后部", "腰部"}
+
+    lines.append("")
+    lines.append(
+        "-- 标准穴位:艾灸椅=是 ∧ 身体部位∈(背部,颈部) ∧ 部位分区∈(背部,骶区,颈后部,腰部)"
+    )
+    lines.append(
+        "INSERT IGNORE INTO acupoint (serial_no, code, name, side_type, meridian, body_region, region_partition, "
+        "location_text, relative_position_text, indications, symptom_coverage, acupoint_method, chair_product, remark) VALUES "
+    )
+    vals = []
+    for row in ws.iter_rows(min_row=2, values_only=True):
+        if not row or row[idx_chair] != "是":
+            continue
+        r = list(row) + [None] * 20
+
+        def g(key):
+            i = cols[key]
+            v = r[i] if i < len(r) else None
+            return v
+
+        br = g("body_region")
+        rp = g("region_partition")
+        if br is None or rp is None:
+            continue
+        brs = str(br).strip()
+        rps = str(rp).strip()
+        if brs not in allowed_body or rps not in allowed_partition:
+            continue
+
+        remark = r[cols["remark"]] if cols["remark"] < len(r) else None
+        vals.append(
+            "({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}, {10}, {11}, 1, {12})".format(
+                esc(g("serial_no")),
+                esc(g("code")),
+                esc(g("name")),
+                esc(g("side_type")),
+                esc(g("meridian")),
+                esc(g("body_region")),
+                esc(g("region_partition")),
+                esc(g("location_text")),
+                esc(g("relative_position_text")),
+                esc(g("indications")),
+                esc(g("symptom_coverage")),
+                esc(g("acupoint_method")),
+                esc(remark),
+            )
+        )
+    lines.append(",\n".join(vals) + ";")
+
+    bcrypt_password = "$2b$10$yW9CwM2u/vqz4CGZsQXPyeiKYtsu4PbAj.vKstKoY4hbFT6I0LiNm"
+    lines.append("")
+    lines.append("-- 后台角色、默认管理员、默认设备(联调)")
+    lines.append(
+        "INSERT IGNORE INTO admin_role (code, name) VALUES "
+        "('SUPER_ADMIN', '超级管理员'), ('ADMIN', '管理员'), ('OPERATOR', '运营');"
+    )
+    lines.append(
+        "INSERT IGNORE INTO admin_user (username, password_hash, display_name, status) VALUES "
+        "('admin', '{0}', '系统管理员', 'ACTIVE');".format(bcrypt_password)
+    )
+    lines.append("INSERT IGNORE INTO admin_user_role (admin_user_id, admin_role_id) VALUES (1, 1);")
+    lines.append(
+        "INSERT IGNORE INTO device_model (code, name, stroke_range_x, stroke_range_y, stroke_range_z, "
+        "origin_offset_x, origin_offset_y, origin_offset_z, safety_distance) VALUES "
+        "('CHAIR-DEFAULT', '默认艾灸椅', 800, 600, 400, 0, 0, 0, 10);"
+    )
+    lines.append("-- INSERT IGNORE 不更新已存在的 admin 行:将管理员 admin 密码统一为明文 admin(BCrypt)")
+    lines.append(
+        "UPDATE admin_user SET password_hash = '{0}' WHERE username = 'admin' AND deleted = 0;".format(
+            bcrypt_password
+        )
+    )
+    lines.append("")
+    lines.append("SET FOREIGN_KEY_CHECKS = 1;")
+
+    with open(V1_SQL, encoding="utf-8") as f:
+        content = f.read()
+    clines = content.splitlines()
+    si = ei = None
+    for i, L in enumerate(clines):
+        if L.strip() == START_TAG:
+            si = i
+        elif si is not None and L.strip() == END_TAG:
+            ei = i
+            break
+    if si is None or ei is None or ei <= si:
+        raise SystemExit("{0} 中缺少 {1} / {2} 标记".format(V1_SQL, START_TAG, END_TAG))
+
+    new_clines = clines[: si + 1] + lines + clines[ei:]
+    with open(V1_SQL, "w", encoding="utf-8", newline="\n") as f:
+        f.write("\n".join(new_clines) + "\n")
+
+    print("Updated seed in", V1_SQL, "acupoints:", len(vals))
+    wb.close()
+
+
+if __name__ == "__main__":
+    main()

+ 17 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/AdminApplication.java

@@ -0,0 +1,17 @@
+package com.aijiuyi.admin;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * 艾灸 PC 后台应用入口:整合 Spring Boot、MyBatis-Plus、安全认证;库表由 resources/db/migration 下 SQL 手工维护或自行导入。
+ */
+@SpringBootApplication
+@MapperScan("com.aijiuyi.admin.mapper")
+public class AdminApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(AdminApplication.class, args);
+    }
+}

+ 32 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/ApiResponse.java

@@ -0,0 +1,32 @@
+package com.aijiuyi.admin.common;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 统一 REST 响应体:code(0 成功)、message、data。
+ *
+ * @param <T> 业务数据类型
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiResponse<T> {
+
+    private int code;
+    private String message;
+    private T data;
+
+    public static <T> ApiResponse<T> ok(T data) {
+        return new ApiResponse<>(0, "OK", data);
+    }
+
+    public static <T> ApiResponse<T> ok() {
+        return ok(null);
+    }
+
+    public static <T> ApiResponse<T> fail(int code, String message) {
+        return new ApiResponse<>(code, message, null);
+    }
+}

+ 22 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/BusinessException.java

@@ -0,0 +1,22 @@
+package com.aijiuyi.admin.common;
+
+/**
+ * 业务异常:携带业务错误码与提示信息,由 {@link GlobalExceptionHandler} 转为 {@link ApiResponse}。
+ */
+public class BusinessException extends RuntimeException {
+
+    private final int code;
+
+    public BusinessException(String message) {
+        this(400, message);
+    }
+
+    public BusinessException(int code, String message) {
+        super(message);
+        this.code = code;
+    }
+
+    public int getCode() {
+        return code;
+    }
+}

+ 54 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/GlobalExceptionHandler.java

@@ -0,0 +1,54 @@
+package com.aijiuyi.admin.common;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * 全局异常处理:业务异常、参数校验、认证与通用异常的统一 JSON 响应。
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    @ExceptionHandler(BusinessException.class)
+    @ResponseStatus(HttpStatus.OK)
+    public ApiResponse<Void> handleBusiness(BusinessException e) {
+        return ApiResponse.fail(e.getCode(), e.getMessage());
+    }
+
+    @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public ApiResponse<Void> handleValidation(Exception e) {
+        String msg = e.getMessage();
+        if (e instanceof MethodArgumentNotValidException) {
+            MethodArgumentNotValidException ex = (MethodArgumentNotValidException) e;
+            if (ex.getBindingResult().getFieldError() != null) {
+                msg = ex.getBindingResult().getFieldError().getDefaultMessage();
+            }
+        }
+        return ApiResponse.fail(400, msg);
+    }
+
+    @ExceptionHandler(BadCredentialsException.class)
+    @ResponseStatus(HttpStatus.UNAUTHORIZED)
+    public ApiResponse<Void> handleBadCredentials(BadCredentialsException e) {
+        return ApiResponse.fail(401, "用户名或密码错误");
+    }
+
+    @ExceptionHandler(AccessDeniedException.class)
+    @ResponseStatus(HttpStatus.FORBIDDEN)
+    public ApiResponse<Void> handleDenied(AccessDeniedException e) {
+        return ApiResponse.fail(403, "无权限");
+    }
+
+    @ExceptionHandler(Exception.class)
+    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+    public ApiResponse<Void> handleOther(Exception e) {
+        return ApiResponse.fail(500, e.getMessage() != null ? e.getMessage() : "服务器错误");
+    }
+}

+ 33 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/common/PageResult.java

@@ -0,0 +1,33 @@
+package com.aijiuyi.admin.common;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 分页列表对外结构,与 MyBatis-Plus {@link com.baomidou.mybatisplus.core.metadata.IPage} 对齐。
+ *
+ * @param <T> 记录类型
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class PageResult<T> {
+
+    private List<T> records;
+    private long total;
+    private long current;
+    private long size;
+
+    public static <T> PageResult<T> of(IPage<T> page) {
+        return new PageResult<>(
+                page.getRecords(),
+                page.getTotal(),
+                page.getCurrent(),
+                page.getSize()
+        );
+    }
+}

+ 43 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/config/MybatisPlusConfig.java

@@ -0,0 +1,43 @@
+package com.aijiuyi.admin.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.apache.ibatis.reflection.MetaObject;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.LocalDateTime;
+
+/**
+ * MyBatis-Plus 全局配置:分页插件(MySQL)、插入/更新时自动填充 createTime、updateTime、createdAt。
+ */
+@Configuration
+public class MybatisPlusConfig {
+
+    @Bean
+    public MybatisPlusInterceptor mybatisPlusInterceptor() {
+        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+        return interceptor;
+    }
+
+    @Bean
+    public MetaObjectHandler metaObjectHandler() {
+        return new MetaObjectHandler() {
+            @Override
+            public void insertFill(MetaObject metaObject) {
+                LocalDateTime now = LocalDateTime.now();
+                strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
+                strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
+                strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
+            }
+
+            @Override
+            public void updateFill(MetaObject metaObject) {
+                strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+            }
+        };
+    }
+}

+ 33 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/config/OpenApiConfig.java

@@ -0,0 +1,33 @@
+package com.aijiuyi.admin.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * OpenAPI 3 / Swagger UI 文档配置:API 说明与全局 Bearer JWT 安全方案。
+ */
+@Configuration
+public class OpenApiConfig {
+
+    @Bean
+    public OpenAPI openAPI() {
+        final String scheme = "bearerAuth";
+        return new OpenAPI()
+                .info(new Info()
+                        .title("艾灸 PC 后台 API")
+                        .description("功效库、穴位、手法、算法、用户、系统管理(JWT)")
+                        .version("0.1.0"))
+                .addSecurityItem(new SecurityRequirement().addList(scheme))
+                .components(new Components().addSecuritySchemes(scheme,
+                        new SecurityScheme()
+                                .name(scheme)
+                                .type(SecurityScheme.Type.HTTP)
+                                .scheme("bearer")
+                                .bearerFormat("JWT")));
+    }
+}

+ 118 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/config/SecurityConfig.java

@@ -0,0 +1,118 @@
+package com.aijiuyi.admin.config;
+
+import com.aijiuyi.admin.security.JwtAuthenticationFilter;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.MediaType;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Spring Security 配置:无状态 JWT、CORS、公开登录与 Swagger 路径,其余接口需认证。
+ */
+@Configuration
+@EnableWebSecurity
+@EnableGlobalMethodSecurity(prePostEnabled = true)
+public class SecurityConfig {
+
+    private final JwtAuthenticationFilter jwtAuthenticationFilter;
+
+    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
+        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
+    }
+
+    @Bean
+    public PasswordEncoder passwordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+
+    @Bean
+    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+        http.csrf().disable()
+                .cors().configurationSource(corsConfigurationSource())
+                .and()
+                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+                .and()
+                .exceptionHandling()
+                    .authenticationEntryPoint(authenticationEntryPoint())
+                    .accessDeniedHandler(accessDeniedHandler())
+                .and()
+                .authorizeRequests()
+                .antMatchers("/", "/favicon.ico").permitAll()
+                .antMatchers("/api/auth/login").permitAll()
+                .antMatchers("/api/expert/**").permitAll()
+                .antMatchers(
+                        "/swagger-ui/**",
+                        "/swagger-ui.html",
+                        "/v3/api-docs/**",
+                        "/v3/api-docs",
+                        "/swagger-resources/**",
+                        "/webjars/**"
+                ).permitAll()
+                .antMatchers("/error").permitAll()
+                .anyRequest().authenticated()
+                .and()
+                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+        return http.build();
+    }
+
+    @Bean
+    public AuthenticationEntryPoint authenticationEntryPoint() {
+        return (request, response, authException) -> {
+            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+            response.setCharacterEncoding("UTF-8");
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("code", 401);
+            body.put("message", "登录已失效,请重新登录");
+            body.put("data", null);
+            new ObjectMapper().writeValue(response.getOutputStream(), body);
+        };
+    }
+
+    @Bean
+    public AccessDeniedHandler accessDeniedHandler() {
+        return (request, response, accessDeniedException) -> {
+            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+            response.setCharacterEncoding("UTF-8");
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("code", 403);
+            body.put("message", "无权限");
+            body.put("data", null);
+            new ObjectMapper().writeValue(response.getOutputStream(), body);
+        };
+    }
+
+    @Bean
+    public CorsConfigurationSource corsConfigurationSource() {
+        CorsConfiguration cfg = new CorsConfiguration();
+        cfg.setAllowedOriginPatterns(Collections.singletonList("*"));
+        cfg.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+        cfg.setAllowedHeaders(Collections.singletonList("*"));
+        cfg.setExposedHeaders(Collections.singletonList(HttpHeaders.AUTHORIZATION));
+        cfg.setAllowCredentials(false);
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        source.registerCorsConfiguration("/**", cfg);
+        return source;
+    }
+}

+ 12 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/BatchIdsRequest.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import java.util.List;
+
+@Data
+public class BatchIdsRequest {
+    @NotEmpty(message = "ID列表不能为空")
+    private List<Long> ids;
+}

+ 18 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/CreateAdminRequest.java

@@ -0,0 +1,18 @@
+package com.aijiuyi.admin.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+
+@Data
+public class CreateAdminRequest {
+    @NotBlank @Size(min = 4, max = 20)
+    private String username;
+    @NotBlank @Size(min = 8, max = 20)
+    private String password;
+    @NotBlank
+    private String displayName;
+    @NotBlank
+    private String role;
+}

+ 13 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/LoginRequest.java

@@ -0,0 +1,13 @@
+package com.aijiuyi.admin.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class LoginRequest {
+    @NotBlank
+    private String username;
+    @NotBlank
+    private String password;
+}

+ 15 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/LoginResponse.java

@@ -0,0 +1,15 @@
+package com.aijiuyi.admin.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class LoginResponse {
+    private String token;
+    private String username;
+    private String displayName;
+    private String role;
+}

+ 49 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/PushDataResponse.java

@@ -0,0 +1,49 @@
+package com.aijiuyi.admin.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class PushDataResponse {
+    private Long userId;
+    private String userName;
+    private BodyDataDTO bodyData;
+    private String mode;
+    private SchemeDataDTO scheme;
+
+    @Data
+    public static class BodyDataDTO {
+        private BigDecimal shoulderWidth;
+        private BigDecimal bodyLength;
+        private FingerWidthDTO fingerWidth;
+    }
+
+    @Data
+    public static class FingerWidthDTO {
+        private BigDecimal cun1;
+        private BigDecimal cun1_5;
+        private BigDecimal cun3;
+    }
+
+    @Data
+    public static class SchemeDataDTO {
+        private Long schemeId;
+        private String schemeName;
+        private List<StepDataDTO> steps;
+    }
+
+    @Data
+    public static class StepDataDTO {
+        private int stepOrder;
+        private String acupointId;
+        private String acupointName;
+        private BigDecimal offsetX;
+        private BigDecimal offsetY;
+        private BigDecimal temperature;
+        private int duration;
+        private String techniqueId;
+        private String techniqueName;
+    }
+}

+ 18 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/SchemeDetailResponse.java

@@ -0,0 +1,18 @@
+package com.aijiuyi.admin.dto;
+
+import com.aijiuyi.admin.entity.MoxaSessionRecord;
+import com.aijiuyi.admin.entity.Scheme;
+import com.aijiuyi.admin.entity.SchemeStep;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class SchemeDetailResponse {
+    private Scheme scheme;
+    private List<SchemeStep> steps;
+    private int totalDurationSeconds;
+    private Map<String, Object> statistics;
+    private List<MoxaSessionRecord> recentRecords;
+}

+ 14 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/UpdatePasswordRequest.java

@@ -0,0 +1,14 @@
+package com.aijiuyi.admin.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+
+@Data
+public class UpdatePasswordRequest {
+    @NotBlank
+    private String oldPassword;
+    @NotBlank @Size(min = 8, max = 20)
+    private String newPassword;
+}

+ 17 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/dto/UserModeStorageResponse.java

@@ -0,0 +1,17 @@
+package com.aijiuyi.admin.dto;
+
+import com.aijiuyi.admin.entity.DeviceHotkeyMapping;
+import com.aijiuyi.admin.entity.Scheme;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class UserModeStorageResponse {
+    private Long userId;
+    private Scheme oneClickScheme;
+    private List<Scheme> professionalSchemes;
+    private List<Scheme> customSchemes;
+    private List<Scheme> expertSchemes;
+    private List<DeviceHotkeyMapping> syncedHotkeys;
+}

+ 29 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/Acupoint.java

@@ -0,0 +1,29 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("acupoint")
+public class Acupoint extends BaseEntity {
+
+    private String code;
+    private String name;
+    private String meridian;
+    private String efficacy;
+    private String positioning;
+    private BigDecimal offsetCun;
+    private String cunType;
+    private String side;
+    private BigDecimal lateralOffset;
+    private BigDecimal absoluteX;
+    private BigDecimal absoluteY;
+    private BigDecimal absoluteZ;
+    private String targetGender;
+    private String status;
+    private String source;
+}

+ 22 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppDevice.java

@@ -0,0 +1,22 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("app_device")
+public class AppDevice extends BaseEntity {
+
+    private String deviceCode;
+    private String deviceName;
+    private String model;
+    private String serialNumber;
+    private String firmwareVersion;
+    private String status;
+    private LocalDateTime lastOnlineAt;
+    private String remark;
+}

+ 28 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppUser.java

@@ -0,0 +1,28 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("app_user")
+public class AppUser extends BaseEntity {
+
+    private String name;
+    private String gender;
+    private Integer age;
+    private String phone;
+    private BigDecimal shoulderWidth;
+    private BigDecimal bodyLength;
+    private BigDecimal fingerCun1;
+
+    @TableField("finger_cun1_5")
+    private BigDecimal fingerCun1_5;
+
+    private BigDecimal fingerCun3;
+    private String acupointTableStatus;
+}

+ 18 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppUserDevice.java

@@ -0,0 +1,18 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("app_user_device")
+public class AppUserDevice extends BaseEntity {
+
+    private Long userId;
+    private Long deviceId;
+    private Integer isPrimary;
+    private LocalDateTime boundAt;
+}

+ 14 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/AppUserFingerprint.java

@@ -0,0 +1,14 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("app_user_fingerprint")
+public class AppUserFingerprint extends BaseEntity {
+
+    private Long userId;
+    private String fingerprintId;
+}

+ 30 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/BaseEntity.java

@@ -0,0 +1,30 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 带逻辑删除与审计时间的实体基类,对应表中 deleted、create_time、update_time。
+ */
+@Data
+public abstract class BaseEntity implements Serializable {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableLogic
+    private Integer deleted;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 21 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/DeviceHotkeyMapping.java

@@ -0,0 +1,21 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("device_hotkey_mapping")
+public class DeviceHotkeyMapping extends BaseEntity {
+
+    private Long userId;
+    private Long deviceId;
+    private Integer slot;
+    private Long schemeId;
+    private String schemeName;
+    private Long processId;
+    private LocalDateTime syncedAt;
+}

+ 15 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/MoxaProcess.java

@@ -0,0 +1,15 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("moxa_process")
+public class MoxaProcess extends BaseEntity {
+
+    private Long schemeId;
+    private String processData;
+    private Integer version;
+}

+ 22 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/MoxaSessionRecord.java

@@ -0,0 +1,22 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("moxa_session_record")
+public class MoxaSessionRecord extends BaseEntity {
+
+    private Long userId;
+    private Long deviceId;
+    private Long schemeId;
+    private LocalDateTime startedAt;
+    private LocalDateTime endedAt;
+    private Integer duration;
+    private Integer rating;
+    private String remark;
+}

+ 27 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/MoxaTechnique.java

@@ -0,0 +1,27 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("moxa_technique")
+public class MoxaTechnique extends BaseEntity {
+
+    private String code;
+    private String name;
+    private Integer isDefault;
+    private String description;
+    private String movementPattern;
+    private BigDecimal tempMin;
+    private BigDecimal tempMax;
+    private Integer durationMin;
+    private Integer durationMax;
+    private BigDecimal movementAmplitude;
+    private BigDecimal movementSpeed;
+    private String status;
+    private String remark;
+}

+ 29 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/Scheme.java

@@ -0,0 +1,29 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("scheme")
+public class Scheme extends BaseEntity {
+
+    private String schemeName;
+    private String mode;
+    private String efficacyType;
+    private String creatorType;
+    private String creatorId;
+    private String creatorName;
+    private String symptoms;
+    private String targetGender;
+    private Integer ageMin;
+    private Integer ageMax;
+    private String description;
+    private Integer isDefault;
+    private String expertId;
+    private String expertName;
+    private String publishType;
+    private Long processId;
+    private String status;
+}

+ 24 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SchemeStep.java

@@ -0,0 +1,24 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("scheme_step")
+public class SchemeStep extends BaseEntity {
+
+    private Long schemeId;
+    private Integer stepOrder;
+    private Long acupointId;
+    private String acupointName;
+    private String side;
+    private BigDecimal temperature;
+    private Integer duration;
+    private Long techniqueId;
+    private String techniqueName;
+    private String remark;
+}

+ 29 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SchemeTargetUser.java

@@ -0,0 +1,29 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("scheme_target_user")
+public class SchemeTargetUser implements Serializable {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    private Long schemeId;
+    private Long userId;
+
+    @TableLogic
+    private Integer deleted;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 22 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SysAdminUser.java

@@ -0,0 +1,22 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_admin_user")
+public class SysAdminUser extends BaseEntity {
+
+    private String username;
+    @JsonIgnore
+    private String passwordHash;
+    private String displayName;
+    private String role;
+    private String status;
+    private LocalDateTime lastLoginAt;
+}

+ 29 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/SysOperationLog.java

@@ -0,0 +1,29 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("sys_operation_log")
+public class SysOperationLog implements Serializable {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    private Long operatorId;
+    private String operatorName;
+    private String actionType;
+    private String module;
+    private String content;
+    private String ip;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 24 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/UserAcupointItem.java

@@ -0,0 +1,24 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("user_acupoint_item")
+public class UserAcupointItem extends BaseEntity {
+
+    private Long tableId;
+    private Long userId;
+    private Long acupointId;
+    private String acupointCode;
+    private String acupointName;
+    private String side;
+    private BigDecimal offsetX;
+    private BigDecimal offsetY;
+    private BigDecimal offsetInCun;
+    private String cunType;
+}

+ 25 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/UserAcupointTable.java

@@ -0,0 +1,25 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("user_acupoint_table")
+public class UserAcupointTable extends BaseEntity {
+
+    private Long userId;
+    private BigDecimal shoulderWidth;
+    private BigDecimal bodyLength;
+    private BigDecimal fingerCun1;
+    @TableField("finger_cun1_5")
+    private BigDecimal fingerCun1_5;
+    private BigDecimal fingerCun3;
+    private Integer isEstimated;
+    private BigDecimal confidence;
+    private BigDecimal precisionCm;
+}

+ 18 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/entity/UserScheme.java

@@ -0,0 +1,18 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("user_scheme")
+public class UserScheme extends BaseEntity {
+
+    private Long userId;
+    private Long schemeId;
+    private String mode;
+    private LocalDateTime selectedAt;
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AcupointMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.Acupoint;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface AcupointMapper extends BaseMapper<Acupoint> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppDeviceMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AppDevice;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface AppDeviceMapper extends BaseMapper<AppDevice> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppUserDeviceMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AppUserDevice;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface AppUserDeviceMapper extends BaseMapper<AppUserDevice> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppUserFingerprintMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AppUserFingerprint;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface AppUserFingerprintMapper extends BaseMapper<AppUserFingerprint> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/AppUserMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AppUser;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface AppUserMapper extends BaseMapper<AppUser> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/DeviceHotkeyMappingMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.DeviceHotkeyMapping;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface DeviceHotkeyMappingMapper extends BaseMapper<DeviceHotkeyMapping> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/MoxaProcessMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.MoxaProcess;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface MoxaProcessMapper extends BaseMapper<MoxaProcess> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/MoxaSessionRecordMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.MoxaSessionRecord;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface MoxaSessionRecordMapper extends BaseMapper<MoxaSessionRecord> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/MoxaTechniqueMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.MoxaTechnique;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface MoxaTechniqueMapper extends BaseMapper<MoxaTechnique> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SchemeMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.Scheme;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface SchemeMapper extends BaseMapper<Scheme> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SchemeStepMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.SchemeStep;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface SchemeStepMapper extends BaseMapper<SchemeStep> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SchemeTargetUserMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.SchemeTargetUser;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface SchemeTargetUserMapper extends BaseMapper<SchemeTargetUser> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SysAdminUserMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.SysAdminUser;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface SysAdminUserMapper extends BaseMapper<SysAdminUser> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/SysOperationLogMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.SysOperationLog;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface SysOperationLogMapper extends BaseMapper<SysOperationLog> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointItemMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.UserAcupointItem;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface UserAcupointItemMapper extends BaseMapper<UserAcupointItem> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointTableMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.UserAcupointTable;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface UserAcupointTableMapper extends BaseMapper<UserAcupointTable> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/mapper/UserSchemeMapper.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.UserScheme;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface UserSchemeMapper extends BaseMapper<UserScheme> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/AdminDetailsService.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.security;
+
+import org.springframework.security.core.userdetails.UserDetailsService;
+
+/** 按用户名加载后台管理员及角色,供 JWT 与 Security 使用。 */
+public interface AdminDetailsService extends UserDetailsService {
+}

+ 28 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/AdminDetailsServiceImpl.java

@@ -0,0 +1,28 @@
+package com.aijiuyi.admin.security;
+
+import com.aijiuyi.admin.entity.SysAdminUser;
+import com.aijiuyi.admin.mapper.SysAdminUserMapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+@Service
+public class AdminDetailsServiceImpl implements AdminDetailsService {
+
+    private final SysAdminUserMapper adminUserMapper;
+
+    public AdminDetailsServiceImpl(SysAdminUserMapper adminUserMapper) {
+        this.adminUserMapper = adminUserMapper;
+    }
+
+    @Override
+    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+        SysAdminUser user = adminUserMapper.selectOne(Wrappers.<SysAdminUser>lambdaQuery()
+                .eq(SysAdminUser::getUsername, username));
+        if (user == null) {
+            throw new UsernameNotFoundException("管理员不存在: " + username);
+        }
+        return new AdminUserDetails(user);
+    }
+}

+ 53 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/AdminUserDetails.java

@@ -0,0 +1,53 @@
+package com.aijiuyi.admin.security;
+
+import com.aijiuyi.admin.entity.SysAdminUser;
+import lombok.Getter;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+@Getter
+public class AdminUserDetails implements UserDetails {
+
+    private final SysAdminUser user;
+    private final List<GrantedAuthority> authorities;
+
+    public AdminUserDetails(SysAdminUser user) {
+        this.user = user;
+        this.authorities = Collections.singletonList(
+                new SimpleGrantedAuthority("ROLE_" + user.getRole()));
+    }
+
+    @Override
+    public Collection<? extends GrantedAuthority> getAuthorities() {
+        return authorities;
+    }
+
+    @Override
+    public String getPassword() {
+        return user.getPasswordHash();
+    }
+
+    @Override
+    public String getUsername() {
+        return user.getUsername();
+    }
+
+    @Override
+    public boolean isAccountNonExpired() { return true; }
+
+    @Override
+    public boolean isAccountNonLocked() { return true; }
+
+    @Override
+    public boolean isCredentialsNonExpired() { return true; }
+
+    @Override
+    public boolean isEnabled() {
+        return "ACTIVE".equalsIgnoreCase(user.getStatus());
+    }
+}

+ 55 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/JwtAuthenticationFilter.java

@@ -0,0 +1,55 @@
+package com.aijiuyi.admin.security;
+
+import io.jsonwebtoken.Claims;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 从 Authorization: Bearer 解析 JWT,加载 {@link AdminUserDetails} 并写入 SecurityContext。
+ */
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+    private final JwtTokenProvider jwtTokenProvider;
+    private final AdminDetailsService adminDetailsService;
+
+    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, AdminDetailsService adminDetailsService) {
+        this.jwtTokenProvider = jwtTokenProvider;
+        this.adminDetailsService = adminDetailsService;
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException {
+        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
+        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
+            String raw = header.substring(7);
+            try {
+                Claims claims = jwtTokenProvider.parse(raw);
+                String username = claims.getSubject();
+                if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
+                    UserDetails details = adminDetailsService.loadUserByUsername(username);
+                    UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
+                            details, null, details.getAuthorities());
+                    auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+                    SecurityContextHolder.getContext().setAuthentication(auth);
+                }
+            } catch (Exception ignored) {
+                SecurityContextHolder.clearContext();
+            }
+        }
+        filterChain.doFilter(request, response);
+    }
+}

+ 70 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/JwtTokenProvider.java

@@ -0,0 +1,70 @@
+package com.aijiuyi.admin.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * JWT 生成与解析:使用 HS256,密钥由配置 jwt.secret 经 SHA-256 派生为 32 字节。
+ */
+@Component
+public class JwtTokenProvider {
+
+    private final SecretKey key;
+    private final long expirationMs;
+
+    public JwtTokenProvider(
+            @Value("${jwt.secret}") String secret,
+            @Value("${jwt.expiration-ms:86400000}") long expirationMs) {
+        this.key = Keys.hmacShaKeyFor(sha256(secret));
+        this.expirationMs = expirationMs;
+    }
+
+    private static byte[] sha256(String secret) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            return md.digest(secret.getBytes(StandardCharsets.UTF_8));
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /** 签发访问令牌,载荷含用户名、用户 ID、逗号分隔的角色编码。 */
+    public String createToken(String username, Long userId, List<String> roleCodes) {
+        Date now = new Date();
+        Date exp = new Date(now.getTime() + expirationMs);
+        String roles = roleCodes == null ? "" : String.join(",", roleCodes);
+        return Jwts.builder()
+                .setSubject(username)
+                .claim("uid", userId)
+                .claim("roles", roles)
+                .setIssuedAt(now)
+                .setExpiration(exp)
+                .signWith(key, SignatureAlgorithm.HS256)
+                .compact();
+    }
+
+    /** 解析并校验 JWT,异常由调用方处理。 */
+    public Claims parse(String token) {
+        return Jwts.parserBuilder()
+                .setSigningKey(key)
+                .build()
+                .parseClaimsJws(token)
+                .getBody();
+    }
+
+    /** 解析 token 并返回 JWT subject(用户名)。 */
+    public String getUsername(String token) {
+        return parse(token).getSubject();
+    }
+}

+ 57 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/security/OperationLogAspect.java

@@ -0,0 +1,57 @@
+package com.aijiuyi.admin.security;
+
+import com.aijiuyi.admin.entity.SysOperationLog;
+import com.aijiuyi.admin.service.SysOperationLogService;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+
+@Aspect
+@Component
+public class OperationLogAspect {
+
+    private final SysOperationLogService logService;
+
+    public OperationLogAspect(SysOperationLogService logService) {
+        this.logService = logService;
+    }
+
+    @Around("execution(* com.aijiuyi.admin.web..*Controller.*(..)) && !execution(* com.aijiuyi.admin.web.AuthController.*(..))")
+    public Object around(ProceedingJoinPoint pjp) throws Throwable {
+        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        if (attrs == null) {
+            return pjp.proceed();
+        }
+        HttpServletRequest request = attrs.getRequest();
+        String method = request.getMethod();
+        if (!("POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method) || "DELETE".equals(method))) {
+            return pjp.proceed();
+        }
+        Object result = pjp.proceed();
+        try {
+            SysOperationLog log = new SysOperationLog();
+            log.setActionType(method);
+            log.setModule(pjp.getTarget().getClass().getSimpleName().replace("Controller", "").toUpperCase());
+            MethodSignature sig = (MethodSignature) pjp.getSignature();
+            log.setContent(method + " " + request.getRequestURI() + " - " + sig.getMethod().getName());
+            log.setIp(request.getRemoteAddr());
+            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+            if (auth != null && auth.getPrincipal() instanceof AdminUserDetails) {
+                AdminUserDetails d = (AdminUserDetails) auth.getPrincipal();
+                log.setOperatorId(d.getUser().getId());
+                log.setOperatorName(d.getUser().getDisplayName());
+            }
+            logService.save(log);
+        } catch (Exception ignored) {
+        }
+        return result;
+    }
+}

+ 12 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AcupointCoordinateService.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.entity.AppUser;
+
+import java.util.Map;
+
+public interface AcupointCoordinateService {
+
+    Map<String, Object> generateCoordinates(AppUser user);
+
+    Map<String, Object> getCoordinates(Long userId);
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AcupointService.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.entity.Acupoint;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+public interface AcupointService extends IService<Acupoint> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppDeviceService.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.entity.AppDevice;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+public interface AppDeviceService extends IService<AppDevice> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppUserDeviceService.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.entity.AppUserDevice;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+public interface AppUserDeviceService extends IService<AppUserDevice> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppUserFingerprintService.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.entity.AppUserFingerprint;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+public interface AppUserFingerprintService extends IService<AppUserFingerprint> {
+}

+ 7 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AppUserService.java

@@ -0,0 +1,7 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.entity.AppUser;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+public interface AppUserService extends IService<AppUser> {
+}

+ 8 - 0
openspec/aijiuyiBackend/src/main/java/com/aijiuyi/admin/service/AuthService.java

@@ -0,0 +1,8 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.dto.LoginRequest;
+import com.aijiuyi.admin.dto.LoginResponse;
+
+public interface AuthService {
+    LoginResponse login(LoginRequest request);
+}

Неке датотеке нису приказане због велике количине промена