فهرست منبع

Merge branch 'master' of http://git.iwintrue.com/liaoxg/ajy

caitianqi 3 ماه پیش
والد
کامیت
bab8104647
82فایلهای تغییر یافته به همراه6375 افزوده شده و 73 حذف شده
  1. 2 0
      .gitignore
  2. 254 1
      README.md
  3. 17 0
      code/ajyApp/App.vue
  4. 20 0
      code/ajyApp/index.html
  5. 22 0
      code/ajyApp/main.js
  6. 112 0
      code/ajyApp/manifest.json
  7. 23 0
      code/ajyApp/pages.json
  8. 52 0
      code/ajyApp/pages/index/index.vue
  9. BIN
      code/ajyApp/static/logo.png
  10. 13 0
      code/ajyApp/uni.promisify.adaptor.js
  11. 76 0
      code/ajyApp/uni.scss
  12. 12 0
      code/ajyApp/uniCloud-alipay/database/JQL查询.jql
  13. 6 0
      code/ajyApp/uni_modules/uni-config-center/changelog.md
  14. 81 0
      code/ajyApp/uni_modules/uni-config-center/package.json
  15. 93 0
      code/ajyApp/uni_modules/uni-config-center/readme.md
  16. 0 0
      code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/index.js
  17. 13 0
      code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/package.json
  18. 38 0
      code/ajyApp/uni_modules/uni-id-common/changelog.md
  19. 101 0
      code/ajyApp/uni_modules/uni-id-common/package.json
  20. 3 0
      code/ajyApp/uni_modules/uni-id-common/readme.md
  21. 0 0
      code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/index.js
  22. 20 0
      code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/package.json
  23. 7 0
      code/backend/pom.xml
  24. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/OssProperties.java
  25. 33 1
      code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java
  26. 20 2
      code/backend/src/main/java/com/aijiuyi/admin/common/handler/MyMetaObjectHandler.java
  27. 147 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java
  28. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AcupointController.java
  29. 157 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/ContentArticleController.java
  30. 65 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java
  31. 114 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/UserCategoryController.java
  32. 16 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ArticleRejectDTO.java
  33. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ContentArticleQueryDTO.java
  34. 9 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java
  35. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java
  36. 22 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserCategoryQueryDTO.java
  37. 15 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java
  38. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileQueryDTO.java
  39. 74 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/ContentArticle.java
  40. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java
  41. 53 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserCategory.java
  42. 15 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserProfile.java
  43. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/ContentArticleMapper.java
  44. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/UserCategoryMapper.java
  45. 8 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AcupointService.java
  46. 78 0
      code/backend/src/main/java/com/aijiuyi/admin/service/ContentArticleService.java
  47. 59 0
      code/backend/src/main/java/com/aijiuyi/admin/service/UserCategoryService.java
  48. 10 1
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java
  49. 280 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/ContentArticleServiceImpl.java
  50. 4 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/DeviceServiceImpl.java
  51. 167 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserCategoryServiceImpl.java
  52. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserProfileServiceImpl.java
  53. 23 3
      code/backend/src/main/resources/application-dev.yml
  54. 22 0
      code/backend/src/main/resources/application-test.yml
  55. 6 0
      code/backend/src/main/resources/application.yml
  56. 14 1
      code/backend/src/main/resources/mapper/DeviceMapper.xml
  57. 17 0
      code/backend/src/main/resources/mapper/UserProfileMapper.xml
  58. 33 0
      code/backend/src/main/resources/sql/init.sql
  59. 33 0
      code/backend/src/main/resources/sql/migration_content_article.sql
  60. 29 0
      code/backend/src/main/resources/sql/migration_user_category.sql
  61. 23 0
      code/backend/src/main/resources/sql/migration_user_profile_address.sql
  62. 553 0
      code/frontend/package-lock.json
  63. 3 0
      code/frontend/package.json
  64. 8 0
      code/frontend/src/api/acupoint.js
  65. 115 0
      code/frontend/src/api/content.js
  66. 6 0
      code/frontend/src/api/index.js
  67. 62 0
      code/frontend/src/api/userCategory.js
  68. 15 3
      code/frontend/src/components/ExcelExport.vue
  69. 136 0
      code/frontend/src/components/RichEditor.vue
  70. 12 0
      code/frontend/src/router/index.js
  71. 240 9
      code/frontend/src/utils/excel.js
  72. 31 10
      code/frontend/src/views/acupoint/index.vue
  73. 1 0
      code/frontend/src/views/admin/index.vue
  74. 607 0
      code/frontend/src/views/content/index.vue
  75. 201 11
      code/frontend/src/views/device/index.vue
  76. 198 19
      code/frontend/src/views/simulation/index.vue
  77. 356 0
      code/frontend/src/views/system/user-category/index.vue
  78. 45 9
      code/frontend/src/views/user/app-user/index.vue
  79. 254 3
      code/frontend/src/views/user/profile/index.vue
  80. 111 0
      code/内容管理.md
  81. BIN
      code/技术文档-Word版.docx
  82. 729 0
      code/技术文档.md

+ 2 - 0
.gitignore

@@ -23,3 +23,5 @@ Thumbs.db
 
 # AGENTS.md - 本地AI agent指南,不提交
 AGENTS.md
+code/app/
+code/ajyApp/

+ 254 - 1
README.md

@@ -1,2 +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

+ 17 - 0
code/ajyApp/App.vue

@@ -0,0 +1,17 @@
+<script>
+	export default {
+		onLaunch: function() {
+			console.log('App Launch')
+		},
+		onShow: function() {
+			console.log('App Show')
+		},
+		onHide: function() {
+			console.log('App Hide')
+		}
+	}
+</script>
+
+<style>
+	/*每个页面公共css */
+</style>

+ 20 - 0
code/ajyApp/index.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <script>
+      var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
+        CSS.supports('top: constant(a)'))
+      document.write(
+        '<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
+        (coverSupport ? ', viewport-fit=cover' : '') + '" />')
+    </script>
+    <title></title>
+    <!--preload-links-->
+    <!--app-context-->
+  </head>
+  <body>
+    <div id="app"><!--app-html--></div>
+    <script type="module" src="/main.js"></script>
+  </body>
+</html>

+ 22 - 0
code/ajyApp/main.js

@@ -0,0 +1,22 @@
+import App from './App'
+
+// #ifndef VUE3
+import Vue from 'vue'
+import './uni.promisify.adaptor'
+Vue.config.productionTip = false
+App.mpType = 'app'
+const app = new Vue({
+  ...App
+})
+app.$mount()
+// #endif
+
+// #ifdef VUE3
+import { createSSRApp } from 'vue'
+export function createApp() {
+  const app = createSSRApp(App)
+  return {
+    app
+  }
+}
+// #endif

+ 112 - 0
code/ajyApp/manifest.json

@@ -0,0 +1,112 @@
+{
+    "name" : "ajyApp",
+    "appid" : "__UNI__A3304B5",
+    "description" : "",
+    "versionName" : "1.0.1",
+    "versionCode" : 101,
+    "transformPx" : false,
+    /* 5+App特有相关 */
+    "app-plus" : {
+        "usingComponents" : true,
+        "nvueStyleCompiler" : "uni-app",
+        "compilerVersion" : 3,
+        "splashscreen" : {
+            "alwaysShowBeforeRender" : true,
+            "waiting" : true,
+            "autoclose" : true,
+            "delay" : 0
+        },
+        /* 模块配置 */
+        "modules" : {
+            "Bluetooth" : {},
+            "Push" : {}
+        },
+        /* 应用发布信息 */
+        "distribute" : {
+            /* android打包配置 */
+            "android" : {
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
+                    "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+                    "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
+                    "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
+                ]
+            },
+            /* ios打包配置 */
+            "ios" : {
+                "dSYMs" : false
+            },
+            /* SDK配置 */
+            "sdkConfigs" : {
+                "push" : {}
+            },
+            "icons" : {
+                "android" : {
+                    "hdpi" : "unpackage/res/icons/72x72.png",
+                    "xhdpi" : "unpackage/res/icons/96x96.png",
+                    "xxhdpi" : "unpackage/res/icons/144x144.png",
+                    "xxxhdpi" : "unpackage/res/icons/192x192.png"
+                },
+                "ios" : {
+                    "appstore" : "unpackage/res/icons/1024x1024.png",
+                    "ipad" : {
+                        "app" : "unpackage/res/icons/76x76.png",
+                        "app@2x" : "unpackage/res/icons/152x152.png",
+                        "notification" : "unpackage/res/icons/20x20.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "proapp@2x" : "unpackage/res/icons/167x167.png",
+                        "settings" : "unpackage/res/icons/29x29.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "spotlight" : "unpackage/res/icons/40x40.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png"
+                    },
+                    "iphone" : {
+                        "app@2x" : "unpackage/res/icons/120x120.png",
+                        "app@3x" : "unpackage/res/icons/180x180.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "notification@3x" : "unpackage/res/icons/60x60.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "settings@3x" : "unpackage/res/icons/87x87.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png",
+                        "spotlight@3x" : "unpackage/res/icons/120x120.png"
+                    }
+                }
+            }
+        }
+    },
+    /* 快应用特有相关 */
+    "quickapp" : {},
+    /* 小程序特有相关 */
+    "mp-weixin" : {
+        "appid" : "",
+        "setting" : {
+            "urlCheck" : false
+        },
+        "usingComponents" : true
+    },
+    "mp-alipay" : {
+        "usingComponents" : true
+    },
+    "mp-baidu" : {
+        "usingComponents" : true
+    },
+    "mp-toutiao" : {
+        "usingComponents" : true
+    },
+    "uniStatistics" : {
+        "enable" : false
+    },
+    "vueVersion" : "3",
+    "fallbackLocale" : "zh-Hans"
+}

+ 23 - 0
code/ajyApp/pages.json

@@ -0,0 +1,23 @@
+{
+	"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
+	{
+		"path": "pages/ble-demo/ble-demo",
+		"style": {
+			"navigationBarTitleText": "ble-demo"
+		}
+	},
+		{
+			"path": "pages/index/index",
+			"style": {
+				"navigationBarTitleText": "uni-app"
+			}
+		}
+	],
+	"globalStyle": {
+		"navigationBarTextStyle": "black",
+		"navigationBarTitleText": "uni-app",
+		"navigationBarBackgroundColor": "#F8F8F8",
+		"backgroundColor": "#F8F8F8"
+	},
+	"uniIdRouter": {}
+}

+ 52 - 0
code/ajyApp/pages/index/index.vue

@@ -0,0 +1,52 @@
+<template>
+	<view class="content">
+		<image class="logo" src="/static/logo.png"></image>
+		<view class="text-area">
+			<text class="title">{{title}}</text>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		data() {
+			return {
+				title: 'Hello'
+			}
+		},
+		onLoad() {
+
+		},
+		methods: {
+
+		}
+	}
+</script>
+
+<style>
+	.content {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+	}
+
+	.logo {
+		height: 200rpx;
+		width: 200rpx;
+		margin-top: 200rpx;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 50rpx;
+	}
+
+	.text-area {
+		display: flex;
+		justify-content: center;
+	}
+
+	.title {
+		font-size: 36rpx;
+		color: #8f8f94;
+	}
+</style>

BIN
code/ajyApp/static/logo.png


+ 13 - 0
code/ajyApp/uni.promisify.adaptor.js

@@ -0,0 +1,13 @@
+uni.addInterceptor({
+  returnValue (res) {
+    if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
+      return res;
+    }
+    return new Promise((resolve, reject) => {
+      res.then((res) => {
+        if (!res) return resolve(res) 
+        return res[0] ? reject(res[0]) : resolve(res[1])
+      });
+    });
+  },
+});

+ 76 - 0
code/ajyApp/uni.scss

@@ -0,0 +1,76 @@
+/**
+ * 这里是uni-app内置的常用样式变量
+ *
+ * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
+ * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
+ *
+ */
+
+/**
+ * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
+ *
+ * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
+ */
+
+/* 颜色变量 */
+
+/* 行为相关颜色 */
+$uni-color-primary: #007aff;
+$uni-color-success: #4cd964;
+$uni-color-warning: #f0ad4e;
+$uni-color-error: #dd524d;
+
+/* 文字基本颜色 */
+$uni-text-color:#333;//基本色
+$uni-text-color-inverse:#fff;//反色
+$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
+$uni-text-color-placeholder: #808080;
+$uni-text-color-disable:#c0c0c0;
+
+/* 背景颜色 */
+$uni-bg-color:#ffffff;
+$uni-bg-color-grey:#f8f8f8;
+$uni-bg-color-hover:#f1f1f1;//点击状态颜色
+$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
+
+/* 边框颜色 */
+$uni-border-color:#c8c7cc;
+
+/* 尺寸变量 */
+
+/* 文字尺寸 */
+$uni-font-size-sm:12px;
+$uni-font-size-base:14px;
+$uni-font-size-lg:16px;
+
+/* 图片尺寸 */
+$uni-img-size-sm:20px;
+$uni-img-size-base:26px;
+$uni-img-size-lg:40px;
+
+/* Border Radius */
+$uni-border-radius-sm: 2px;
+$uni-border-radius-base: 3px;
+$uni-border-radius-lg: 6px;
+$uni-border-radius-circle: 50%;
+
+/* 水平间距 */
+$uni-spacing-row-sm: 5px;
+$uni-spacing-row-base: 10px;
+$uni-spacing-row-lg: 15px;
+
+/* 垂直间距 */
+$uni-spacing-col-sm: 4px;
+$uni-spacing-col-base: 8px;
+$uni-spacing-col-lg: 12px;
+
+/* 透明度 */
+$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
+
+/* 文章场景相关 */
+$uni-color-title: #2C405A; // 文章标题颜色
+$uni-font-size-title:20px;
+$uni-color-subtitle: #555555; // 二级标题颜色
+$uni-font-size-subtitle:26px;
+$uni-color-paragraph: #3F536E; // 文章段落颜色
+$uni-font-size-paragraph:15px;

+ 12 - 0
code/ajyApp/uniCloud-alipay/database/JQL查询.jql

@@ -0,0 +1,12 @@
+// 本文件用于,使用JQL语法操作项目关联的uniCloud空间的数据库,方便开发调试和远程数据库管理
+// 编写clientDB的js API(也支持常规js语法,比如var),可以对云数据库进行增删改查操作。不支持uniCloud-db组件写法
+// 可以全部运行,也可以选中部分代码运行。点击工具栏上的运行按钮或者按下【F5】键运行代码
+// 如果文档中存在多条JQL语句,只有最后一条语句生效
+// 如果混写了普通js,最后一条语句需是数据库操作语句
+// 此处代码运行不受DB Schema的权限控制,移植代码到实际业务中注意在schema中配好permission
+// 不支持clientDB的action
+// 数据库查询有最大返回条数限制,详见:https://uniapp.dcloud.net.cn/uniCloud/cf-database.html#limit
+// 详细JQL语法,请参考:https://uniapp.dcloud.net.cn/uniCloud/jql.html
+
+// 下面示例查询uni-id-users表的所有数据
+db.collection('uni-id-users').get();

+ 6 - 0
code/ajyApp/uni_modules/uni-config-center/changelog.md

@@ -0,0 +1,6 @@
+## 0.0.3(2022-11-11)
+- 修复 config 方法获取根节点为数组格式配置时错误的转化为了对象的Bug
+## 0.0.2(2021-04-16)
+- 修改插件package信息
+## 0.0.1(2021-03-15)
+- 初始化项目

+ 81 - 0
code/ajyApp/uni_modules/uni-config-center/package.json

@@ -0,0 +1,81 @@
+{
+  "id": "uni-config-center",
+  "displayName": "uni-config-center",
+  "version": "0.0.3",
+  "description": "uniCloud 配置中心",
+  "keywords": [
+    "配置",
+    "配置中心"
+],
+  "repository": "",
+  "engines": {
+    "HBuilderX": "^3.1.0"
+  },
+"dcloudext": {
+    "sale": {
+      "regular": {
+        "price": "0.00"
+      },
+      "sourcecode": {
+        "price": "0.00"
+      }
+    },
+    "contact": {
+      "qq": ""
+    },
+    "declaration": {
+      "ads": "无",
+      "data": "无",
+      "permissions": "无"
+    },
+    "npmurl": "",
+    "type": "unicloud-template-function"
+  },
+  "directories": {
+    "example": "../../../scripts/dist"
+  },
+  "uni_modules": {
+    "dependencies": [],
+    "encrypt": [],
+    "platforms": {
+      "cloud": {
+        "tcb": "y",
+        "aliyun": "y"
+      },
+      "client": {
+        "App": {
+          "app-vue": "u",
+          "app-nvue": "u"
+        },
+        "H5-mobile": {
+          "Safari": "u",
+          "Android Browser": "u",
+          "微信浏览器(Android)": "u",
+          "QQ浏览器(Android)": "u"
+        },
+        "H5-pc": {
+          "Chrome": "u",
+          "IE": "u",
+          "Edge": "u",
+          "Firefox": "u",
+          "Safari": "u"
+        },
+        "小程序": {
+          "微信": "u",
+          "阿里": "u",
+          "百度": "u",
+          "字节跳动": "u",
+          "QQ": "u"
+        },
+        "快应用": {
+          "华为": "u",
+          "联盟": "u"
+        },
+        "Vue": {
+            "vue2": "y",
+            "vue3": "u"
+        }
+      }
+    }
+  }
+}

+ 93 - 0
code/ajyApp/uni_modules/uni-config-center/readme.md

@@ -0,0 +1,93 @@
+# 为什么使用uni-config-center
+
+实际开发中很多插件需要配置文件才可以正常运行,如果每个插件都单独进行配置的话就会产生下面这样的目录结构
+
+```bash
+cloudfunctions
+└─────common 公共模块
+        ├─plugin-a // 插件A对应的目录
+        │  ├─index.js
+        │  ├─config.json // plugin-a对应的配置文件
+        │  └─other-file.cert  // plugin-a依赖的其他文件
+        └─plugin-b // plugin-b对应的目录
+           ├─index.js
+           └─config.json // plugin-b对应的配置文件
+```
+
+假设插件作者要发布一个项目模板,里面使用了很多需要配置的插件,无论是作者发布还是用户使用都是一个大麻烦。
+
+uni-config-center就是用了统一管理这些配置文件的,使用uni-config-center后的目录结构如下
+
+```bash
+cloudfunctions
+└─────common 公共模块
+        ├─plugin-a // 插件A对应的目录
+        │  └─index.js
+        ├─plugin-b // plugin-b对应的目录
+        │  └─index.js
+        └─uni-config-center
+           ├─index.js // config-center入口文件
+           ├─plugin-a
+           │  ├─config.json  // plugin-a对应的配置文件
+           │  └─other-file.cert  // plugin-a依赖的其他文件
+           └─plugin-b
+              └─config.json  // plugin-b对应的配置文件
+```
+
+使用uni-config-center后的优势
+
+- 配置文件统一管理,分离插件主体和配置信息,更新插件更方便
+- 支持对config.json设置schema,插件使用者在HBuilderX内编写config.json文件时会有更好的提示(后续HBuilderX会提供支持)
+
+# 用法
+
+在要使用uni-config-center的公共模块或云函数内引入uni-config-center依赖,请参考:[使用公共模块](https://uniapp.dcloud.net.cn/uniCloud/cf-common)
+
+```js
+const createConfig = require('uni-config-center')
+
+const uniIdConfig = createConfig({
+    pluginId: 'uni-id', // 插件id
+    defaultConfig: { // 默认配置
+        tokenExpiresIn: 7200,
+        tokenExpiresThreshold: 600,
+    },
+    customMerge: function(defaultConfig, userConfig) { // 自定义默认配置和用户配置的合并规则,不设置的情况侠会对默认配置和用户配置进行深度合并
+        // defaudltConfig 默认配置
+        // userConfig 用户配置
+        return Object.assign(defaultConfig, userConfig)
+    }
+})
+
+
+// 以如下配置为例
+// {
+//   "tokenExpiresIn": 7200,
+//   "passwordErrorLimit": 6,
+//   "bindTokenToDevice": false,
+//   "passwordErrorRetryTime": 3600,
+//   "app-plus": {
+//     "tokenExpiresIn": 2592000
+//   },
+//   "service": {
+//     "sms": {
+//       "codeExpiresIn": 300
+//     }
+//   }
+// }
+
+// 获取配置
+uniIdConfig.config() // 获取全部配置,注意:uni-config-center内不存在对应插件目录时会返回空对象
+uniIdConfig.config('tokenExpiresIn') // 指定键值获取配置,返回:7200
+uniIdConfig.config('service.sms.codeExpiresIn') // 指定键值获取配置,返回:300
+uniIdConfig.config('tokenExpiresThreshold', 600) // 指定键值获取配置,如果不存在则取传入的默认值,返回:600
+
+// 获取文件绝对路径
+uniIdConfig.resolve('custom-token.js') // 获取uni-config-center/uni-id/custom-token.js文件的路径
+
+// 引用文件(require)
+uniIDConfig.requireFile('custom-token.js') // 使用require方式引用uni-config-center/uni-id/custom-token.js文件。文件不存在时返回undefined,文件内有其他错误导致require失败时会抛出错误。
+
+// 判断是否包含某文件
+uniIDConfig.hasFile('custom-token.js') // 配置目录是否包含某文件,true: 文件存在,false: 文件不存在
+```

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/index.js


+ 13 - 0
code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/package.json

@@ -0,0 +1,13 @@
+{
+    "name": "uni-config-center",
+    "version": "0.0.3",
+    "description": "配置中心",
+    "main": "index.js",
+    "keywords": [],
+    "author": "DCloud",
+    "license": "Apache-2.0",
+    "origin-plugin-dev-name": "uni-config-center",
+    "origin-plugin-version": "0.0.3",
+    "plugin-dev-name": "uni-config-center",
+    "plugin-version": "0.0.3"
+}

+ 38 - 0
code/ajyApp/uni_modules/uni-id-common/changelog.md

@@ -0,0 +1,38 @@
+## 1.0.19(2025-12-16)
+- 增加配置参数缺失时的错误提示,指明配置文件路径
+## 1.0.18(2024-07-08)
+- checkToken时如果传入的token为空则返回uni-id-check-token-failed错误码以便uniIdRouter能正常跳转
+## 1.0.17(2024-04-26)
+- 兼容uni-app-x对客户端uniPlatform的调整(uni-app-x内uniPlatform区分app-android、app-ios)
+## 1.0.16(2023-04-25)
+- 新增maxTokenLength配置,用于限制数据库用户记录token数组的最大长度
+## 1.0.15(2023-04-06)
+- 修复部分语言国际化出错的Bug
+## 1.0.14(2023-03-07)
+- 修复 admin用户包含其他角色时未包含在token的Bug
+## 1.0.13(2022-07-21)
+- 修复 创建token时未传角色权限信息生成的token不正确的bug
+## 1.0.12(2022-07-15)
+- 提升与旧版本uni-id的兼容性(补充读取配置文件时回退平台app-plus、h5),但是仍推荐使用新平台名进行配置(app、web)
+## 1.0.11(2022-07-14)
+- 修复 部分情况下报`read property 'reduce' of undefined`的错误
+## 1.0.10(2022-07-11)
+- 将token存储在用户表的token字段内,与旧版本uni-id保持一致
+## 1.0.9(2022-07-01)
+- checkToken兼容token内未缓存角色权限的情况,此时将查库获取角色权限
+## 1.0.8(2022-07-01)
+- 修复clientDB默认依赖时部分情况下获取不到uni-id配置的Bug
+## 1.0.7(2022-06-30)
+- 修复config文件不合法时未抛出具体错误的Bug
+## 1.0.6(2022-06-28)
+- 移除插件内的数据表schema
+## 1.0.5(2022-06-27)
+- 修复使用多应用配置时报`Cannot read property 'appId' of undefined`的Bug
+## 1.0.4(2022-06-27)
+- 修复使用自定义token内容功能报错的Bug [详情](https://ask.dcloud.net.cn/question/147945)
+## 1.0.2(2022-06-23)
+- 对齐旧版本uni-id默认配置
+## 1.0.1(2022-06-22)
+- 补充对uni-config-center的依赖
+## 1.0.0(2022-06-21)
+- 提供uni-id token创建、校验、刷新接口,简化旧版uni-id公共模块

+ 101 - 0
code/ajyApp/uni_modules/uni-id-common/package.json

@@ -0,0 +1,101 @@
+{
+  "id": "uni-id-common",
+  "displayName": "uni-id-common",
+  "version": "1.0.19",
+  "description": "包含uni-id token生成、校验、刷新功能的云函数公共模块",
+  "keywords": [
+    "uni-id-common",
+    "uniCloud",
+    "token",
+    "权限"
+],
+  "repository": "https://gitcode.net/dcloud/uni-id-common",
+  "engines": {
+    "uni-app": "^3.1.0",
+    "uni-app-x": "^3.1.0"
+  },
+  "dcloudext": {
+    "sale": {
+      "regular": {
+        "price": "0.00"
+      },
+      "sourcecode": {
+        "price": "0.00"
+      }
+    },
+    "contact": {
+      "qq": ""
+    },
+    "declaration": {
+      "ads": "无",
+      "data": "无",
+      "permissions": "无"
+    },
+    "npmurl": "",
+    "type": "unicloud-template-function",
+    "darkmode": "-",
+    "i18n": "-",
+    "widescreen": "-"
+  },
+  "uni_modules": {
+    "dependencies": [
+      "uni-config-center"
+    ],
+    "encrypt": [],
+    "platforms": {
+      "cloud": {
+        "tcb": "√",
+        "aliyun": "√",
+        "alipay": "√"
+      },
+      "client": {
+        "uni-app": {
+          "vue": {
+            "vue2": "-",
+            "vue3": "-"
+          },
+          "web": {
+            "safari": "-",
+            "chrome": "-"
+          },
+          "app": {
+            "vue": "-",
+            "nvue": "-",
+            "android": "-",
+            "ios": "-",
+            "harmony": "-"
+          },
+          "mp": {
+            "weixin": "-",
+            "alipay": "-",
+            "toutiao": "-",
+            "baidu": "-",
+            "kuaishou": "-",
+            "jd": "-",
+            "harmony": "-",
+            "qq": "-",
+            "lark": "-"
+          },
+          "quickapp": {
+            "huawei": "-",
+            "union": "-"
+          }
+        },
+        "uni-app-x": {
+          "web": {
+            "safari": "-",
+            "chrome": "-"
+          },
+          "app": {
+            "android": "-",
+            "ios": "-",
+            "harmony": "-"
+          },
+          "mp": {
+            "weixin": "-"
+          }
+        }
+      }
+    }
+  }
+}

+ 3 - 0
code/ajyApp/uni_modules/uni-id-common/readme.md

@@ -0,0 +1,3 @@
+# uni-id-common
+
+文档请参考:[uni-id-common](https://uniapp.dcloud.net.cn/uniCloud/uni-id-common.html)

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/index.js


+ 20 - 0
code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/package.json

@@ -0,0 +1,20 @@
+{
+    "name": "uni-id-common",
+    "version": "1.0.19",
+    "description": "uni-id token生成、校验、刷新",
+    "main": "index.js",
+    "homepage": "https:\/\/uniapp.dcloud.io\/uniCloud\/uni-id-common.html",
+    "repository": {
+        "type": "git",
+        "url": "git+https:\/\/gitee.com\/dcloud\/uni-id-common.git"
+    },
+    "author": "DCloud",
+    "license": "Apache-2.0",
+    "dependencies": {
+        "uni-config-center": "file:..\/..\/..\/..\/..\/uni-config-center\/uniCloud\/cloudfunctions\/common\/uni-config-center"
+    },
+    "origin-plugin-dev-name": "uni-id-common",
+    "origin-plugin-version": "1.0.19",
+    "plugin-dev-name": "uni-id-common",
+    "plugin-version": "1.0.19"
+}

+ 7 - 0
code/backend/pom.xml

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

+ 39 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/OssProperties.java

@@ -0,0 +1,39 @@
+package com.aijiuyi.admin.common.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 阿里云 OSS 配置属性
+ * 从 application.yml 中读取 aliyun.oss 前缀的配置
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "aliyun.oss")
+public class OssProperties {
+
+    /** OSS 服务的 Endpoint(例如:https://oss-cn-beijing.aliyuncs.com) */
+    private String endpoint;
+
+    /** AccessKey ID */
+    private String accessKeyId;
+
+    /** AccessKey Secret */
+    private String accessKeySecret;
+
+    /** 存储桶名称 */
+    private String bucketName;
+
+    /** 访问域名(用于拼接公网访问 URL) */
+    private String domain;
+
+    /** 文件在 OSS 中的根目录 */
+    private String baseDir = "content";
+
+    /** 图片最大大小(字节) */
+    private Long maxImageSize = 5242880L;
+
+    /** 视频最大大小(字节) */
+    private Long maxVideoSize = 52428800L;
+}

+ 33 - 1
code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java

@@ -111,7 +111,39 @@ public enum ResultCode {
     /** 不能禁用自身账号 */
     ADMIN_CANNOT_DISABLE_SELF(1802, "不能禁用当前登录账号"),
     /** 管理员角色不合法 */
-    ADMIN_ROLE_INVALID(1803, "管理员角色不合法");
+    ADMIN_ROLE_INVALID(1803, "管理员角色不合法"),
+
+    // ======================== 内容管理相关 1900-1949 ========================
+    /** 文章不存在 */
+    ARTICLE_NOT_FOUND(1900, "文章不存在"),
+    /** 文章状态不允许该操作 */
+    ARTICLE_STATUS_INVALID(1901, "当前状态下不允许该操作"),
+    /** 无权限操作文章 */
+    ARTICLE_PERMISSION_DENIED(1902, "没有权限执行该操作"),
+    /** 拒绝原因不能为空 */
+    ARTICLE_REJECT_REASON_REQUIRED(1903, "拒绝审核必须填写原因"),
+
+    // ======================== 文件上传相关 1950-1969 ========================
+    /** 文件为空 */
+    FILE_EMPTY(1950, "上传文件不能为空"),
+    /** 文件类型不支持 */
+    FILE_TYPE_NOT_SUPPORTED(1951, "文件类型不支持"),
+    /** 文件大小超限 */
+    FILE_SIZE_EXCEEDED(1952, "文件大小超过限制"),
+    /** 文件上传失败 */
+    FILE_UPLOAD_FAILED(1953, "文件上传失败"),
+
+    // ======================== 用户类别相关 1970-1989 ========================
+    /** 用户类别不存在 */
+    USER_CATEGORY_NOT_FOUND(1970, "用户类别不存在"),
+    /** 用户类别编码已存在 */
+    USER_CATEGORY_CODE_EXISTS(1971, "用户类别编码已存在"),
+    /** 用户类别名称已存在 */
+    USER_CATEGORY_NAME_EXISTS(1972, "用户类别名称已存在"),
+    /** 内置用户类别不可删除 */
+    USER_CATEGORY_BUILTIN_FORBIDDEN(1973, "内置用户类别不可删除或修改编码"),
+    /** 用户类别已被使用 */
+    USER_CATEGORY_IN_USE(1974, "用户类别已被用户使用,不可删除");
 
     // ======================== 其他业务错误码在此区间之后继续添加 ========================
 

+ 20 - 2
code/backend/src/main/java/com/aijiuyi/admin/common/handler/MyMetaObjectHandler.java

@@ -1,5 +1,6 @@
 package com.aijiuyi.admin.common.handler;
 
+import com.aijiuyi.admin.common.context.RequestContext;
 import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
 import org.apache.ibatis.reflection.MetaObject;
 import org.springframework.stereotype.Component;
@@ -10,13 +11,14 @@ import java.time.LocalDateTime;
  * MyBatis Plus 自动填充处理器
  * 实体类字段使用 @TableField(fill = FieldFill.INSERT) 标注后,新增时自动填充 createTime
  * 使用 @TableField(fill = FieldFill.INSERT_UPDATE) 标注后,新增和更新时自动填充 updateTime
+ * 同时支持 createBy/updateBy 从当前登录用户自动填充
  */
 @Component
 public class MyMetaObjectHandler implements MetaObjectHandler {
 
     /**
      * 新增时自动填充
-     * 填充字段:createTime(创建时间)、updateTime(更新时间)
+     * 填充字段:createTime、updateTime、createBy、updateBy
      *
      * @param metaObject 元对象
      */
@@ -26,11 +28,22 @@ public class MyMetaObjectHandler implements MetaObjectHandler {
         this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
         // 新增时也填充更新时间
         this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+        // 新增时自动填充创建人和更新人(从当前登录用户获取)
+        Long userId = RequestContext.getUserId();
+        if (userId != null) {
+            // 存在对应字段时才填充,防止影响其他不含该字段的实体
+            if (metaObject.hasSetter("createBy")) {
+                this.strictInsertFill(metaObject, "createBy", Long.class, userId);
+            }
+            if (metaObject.hasSetter("updateBy")) {
+                this.strictInsertFill(metaObject, "updateBy", Long.class, userId);
+            }
+        }
     }
 
     /**
      * 更新时自动填充
-     * 填充字段:updateTime(更新时间)
+     * 填充字段:updateTime、updateBy
      *
      * @param metaObject 元对象
      */
@@ -38,5 +51,10 @@ public class MyMetaObjectHandler implements MetaObjectHandler {
     public void updateFill(MetaObject metaObject) {
         // 更新时自动更新时间
         this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+        // 更新时自动填充更新人
+        Long userId = RequestContext.getUserId();
+        if (userId != null && metaObject.hasSetter("updateBy")) {
+            this.strictUpdateFill(metaObject, "updateBy", Long.class, userId);
+        }
     }
 }

+ 147 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java

@@ -0,0 +1,147 @@
+package com.aijiuyi.admin.common.util;
+
+import com.aijiuyi.admin.common.config.OssProperties;
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import com.aliyun.oss.model.ObjectMetadata;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 阿里云 OSS 上传工具
+ * 负责上传文件到 OSS,返回公网可访问的 URL
+ */
+@Component
+public class OssUtil {
+
+    /** 允许的图片扩展名 */
+    private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp");
+
+    /** 允许的视频扩展名 */
+    private static final List<String> VIDEO_EXTENSIONS = Arrays.asList("mp4", "mov", "avi", "wmv", "flv", "mkv");
+
+    @Autowired
+    private OssProperties ossProperties;
+
+    /**
+     * 上传图片到 OSS
+     *
+     * @param file 上传文件
+     * @return 公网访问 URL
+     */
+    public String uploadImage(MultipartFile file) {
+        checkFile(file);
+        String ext = getExtension(file.getOriginalFilename());
+        if (!IMAGE_EXTENSIONS.contains(ext.toLowerCase())) {
+            throw new BusinessException(ResultCode.FILE_TYPE_NOT_SUPPORTED);
+        }
+        if (file.getSize() > ossProperties.getMaxImageSize()) {
+            throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED);
+        }
+        return doUpload(file, "image", ext);
+    }
+
+    /**
+     * 上传视频到 OSS
+     *
+     * @param file 上传文件
+     * @return 公网访问 URL
+     */
+    public String uploadVideo(MultipartFile file) {
+        checkFile(file);
+        String ext = getExtension(file.getOriginalFilename());
+        if (!VIDEO_EXTENSIONS.contains(ext.toLowerCase())) {
+            throw new BusinessException(ResultCode.FILE_TYPE_NOT_SUPPORTED);
+        }
+        if (file.getSize() > ossProperties.getMaxVideoSize()) {
+            throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED);
+        }
+        return doUpload(file, "video", ext);
+    }
+
+    /**
+     * 执行文件上传到 OSS
+     *
+     * @param file    文件对象
+     * @param subDir  子目录(image/video)
+     * @param ext     文件扩展名
+     * @return 公网访问 URL
+     */
+    private String doUpload(MultipartFile file, String subDir, String ext) {
+        // 构造 OSS 对象键:content/image/2026/05/12/uuid.jpg
+        String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+        String objectKey = String.format("%s/%s/%s/%s.%s",
+                ossProperties.getBaseDir(), subDir, datePath,
+                UUID.randomUUID().toString().replace("-", ""), ext);
+
+        OSS ossClient = null;
+        try (InputStream inputStream = file.getInputStream()) {
+            ossClient = new OSSClientBuilder().build(
+                    ossProperties.getEndpoint(),
+                    ossProperties.getAccessKeyId(),
+                    ossProperties.getAccessKeySecret());
+            ObjectMetadata metadata = new ObjectMetadata();
+            metadata.setContentLength(file.getSize());
+            if (StringUtils.hasText(file.getContentType())) {
+                metadata.setContentType(file.getContentType());
+            }
+            ossClient.putObject(ossProperties.getBucketName(), objectKey, inputStream, metadata);
+            String url = ossProperties.getDomain() + "/" + objectKey;
+            LogUtil.info(OssUtil.class, "OSS 文件上传成功: {} ({} 字节)", url, file.getSize());
+            return url;
+        } catch (IOException e) {
+            LogUtil.error(OssUtil.class, "OSS 文件上传失败", e);
+            throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED);
+        } catch (Exception e) {
+            LogUtil.error(OssUtil.class, "OSS 服务异常", e);
+            throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED);
+        } finally {
+            if (ossClient != null) {
+                ossClient.shutdown();
+            }
+        }
+    }
+
+    /**
+     * 基础文件非空校验
+     *
+     * @param file 上传文件
+     */
+    private void checkFile(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException(ResultCode.FILE_EMPTY);
+        }
+        if (!StringUtils.hasText(file.getOriginalFilename())) {
+            throw new BusinessException(ResultCode.FILE_EMPTY);
+        }
+    }
+
+    /**
+     * 获取文件扩展名(不含点号,小写)
+     *
+     * @param fileName 文件名
+     * @return 扩展名
+     */
+    private String getExtension(String fileName) {
+        if (fileName == null) {
+            return "";
+        }
+        int idx = fileName.lastIndexOf('.');
+        if (idx < 0 || idx == fileName.length() - 1) {
+            return "";
+        }
+        return fileName.substring(idx + 1).toLowerCase();
+    }
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AcupointController.java

@@ -65,6 +65,18 @@ public class AcupointController {
         return Result.success(acupointService.pageList(queryDTO));
     }
 
+    /**
+     * 导出全部穴位(返回当前筛选条件下的全量数据供前端 Excel 导出使用)
+     *
+     * @param queryDTO 查询条件(不含分页参数)
+     * @return 穴位列表
+     */
+    @GetMapping("/export")
+    @Log(value = "导出穴位列表", module = "穴位管理", operationType = OperationType.OTHER)
+    public Result<List<Acupoint>> export(AcupointQueryDTO queryDTO) {
+        return Result.success(acupointService.listAll(queryDTO));
+    }
+
     /**
      * 查询穴位详情
      *

+ 157 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/ContentArticleController.java

@@ -0,0 +1,157 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.ArticleRejectDTO;
+import com.aijiuyi.admin.controller.dto.ContentArticleQueryDTO;
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.aijiuyi.admin.service.ContentArticleService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+
+/**
+ * 内容管理(文章)Controller
+ * 提供文章的增删改查、提交审核、发布、拒绝、上下架接口
+ */
+@RestController
+@RequestMapping("/content/articles")
+public class ContentArticleController {
+
+    @Autowired
+    private ContentArticleService articleService;
+
+    /**
+     * 分页查询文章列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping
+    @Log(value = "查询文章列表", module = "内容管理", operationType = OperationType.QUERY)
+    public Result<IPage<ContentArticle>> page(ContentArticleQueryDTO queryDTO) {
+        return Result.success(articleService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询文章详情
+     *
+     * @param id 文章ID
+     * @return 文章详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询文章详情", module = "内容管理", operationType = OperationType.QUERY)
+    public Result<ContentArticle> getById(@PathVariable Long id) {
+        return Result.success(articleService.getById(id));
+    }
+
+    /**
+     * 新建文章(初始状态为草稿)
+     *
+     * @param article 文章内容
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新建文章", module = "内容管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody ContentArticle article) {
+        articleService.addArticle(article);
+        return Result.success();
+    }
+
+    /**
+     * 编辑文章
+     *
+     * @param id      文章ID
+     * @param article 文章内容
+     * @return 操作结果
+     */
+    @PutMapping("/{id}")
+    @Log(value = "编辑文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@PathVariable Long id, @RequestBody ContentArticle article) {
+        article.setId(id);
+        articleService.updateArticle(article);
+        return Result.success();
+    }
+
+    /**
+     * 删除文章(逻辑删除,仅系统管理员可操作)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除文章", module = "内容管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        articleService.deleteArticle(id);
+        return Result.success();
+    }
+
+    /**
+     * 提交审核(草稿/已拒绝 → 待审核)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/submit")
+    @Log(value = "提交文章审核", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> submit(@PathVariable Long id) {
+        articleService.submitForReview(id);
+        return Result.success();
+    }
+
+    /**
+     * 审核通过并发布(待审核 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/publish")
+    @Log(value = "发布文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> publish(@PathVariable Long id) {
+        articleService.publish(id);
+        return Result.success();
+    }
+
+    /**
+     * 拒绝审核(待审核 → 已拒绝,仅系统管理员)
+     *
+     * @param id         文章ID
+     * @param rejectDTO  拒绝原因
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/reject")
+    @Log(value = "拒绝文章审核", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> reject(@PathVariable Long id, @RequestBody @Valid ArticleRejectDTO rejectDTO) {
+        articleService.reject(id, rejectDTO);
+        return Result.success();
+    }
+
+    /**
+     * 下架(已发布 → 已下架,仅系统管理员)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/offline")
+    @Log(value = "下架文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> offline(@PathVariable Long id) {
+        articleService.offline(id);
+        return Result.success();
+    }
+
+    /**
+     * 重新上架(已下架 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/online")
+    @Log(value = "上架文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> online(@PathVariable Long id) {
+        articleService.online(id);
+        return Result.success();
+    }
+}

+ 65 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java

@@ -0,0 +1,65 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.common.util.OssUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 文件上传 Controller(阿里云 OSS)
+ * 用于内容管理富文本编辑器中的图片/视频上传
+ */
+@RestController
+@RequestMapping("/file")
+public class FileController {
+
+    @Autowired
+    private OssUtil ossUtil;
+
+    /**
+     * 上传图片到 OSS
+     *
+     * @param file 图片文件
+     * @return 公网访问 URL
+     */
+    @PostMapping("/image")
+    @Log(value = "上传图片", module = "文件管理", operationType = OperationType.OTHER)
+    public Result<Map<String, String>> uploadImage(@RequestParam("file") MultipartFile file) {
+        String url = ossUtil.uploadImage(file);
+        return Result.success(buildResult(url));
+    }
+
+    /**
+     * 上传视频到 OSS
+     *
+     * @param file 视频文件
+     * @return 公网访问 URL
+     */
+    @PostMapping("/video")
+    @Log(value = "上传视频", module = "文件管理", operationType = OperationType.OTHER)
+    public Result<Map<String, String>> uploadVideo(@RequestParam("file") MultipartFile file) {
+        String url = ossUtil.uploadVideo(file);
+        return Result.success(buildResult(url));
+    }
+
+    /**
+     * 构造返回结果(同时返回 url 字段和前端富文本编辑器常用的 data 结构)
+     *
+     * @param url OSS 公网访问 URL
+     * @return 返回数据
+     */
+    private Map<String, String> buildResult(String url) {
+        Map<String, String> data = new HashMap<>(2);
+        data.put("url", url);
+        return data;
+    }
+}

+ 114 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/UserCategoryController.java

@@ -0,0 +1,114 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.UserCategoryQueryDTO;
+import com.aijiuyi.admin.entity.UserCategory;
+import com.aijiuyi.admin.service.UserCategoryService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 用户类别管理 Controller
+ * 提供用户类别(用户类型字典)的增删改查接口
+ * 内置类别(普通用户/专家/管理员)不可删除或修改 code
+ */
+@RestController
+@RequestMapping("/user/category")
+public class UserCategoryController {
+
+    @Autowired
+    private UserCategoryService userCategoryService;
+
+    /**
+     * 分页查询用户类别
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询用户类别列表", module = "用户类别管理", operationType = OperationType.QUERY)
+    public Result<IPage<UserCategory>> page(UserCategoryQueryDTO queryDTO) {
+        return Result.success(userCategoryService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询所有启用的用户类别(前端下拉选择使用)
+     *
+     * @return 启用的用户类别列表
+     */
+    @GetMapping("/list")
+    @Log(value = "获取用户类别下拉列表", module = "用户类别管理", operationType = OperationType.QUERY)
+    public Result<List<UserCategory>> list() {
+        return Result.success(userCategoryService.listEnabled());
+    }
+
+    /**
+     * 查询用户类别详情
+     *
+     * @param id 类别ID
+     * @return 类别详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询用户类别详情", module = "用户类别管理", operationType = OperationType.QUERY)
+    public Result<UserCategory> getById(@PathVariable Long id) {
+        return Result.success(userCategoryService.getById(id));
+    }
+
+    /**
+     * 新增用户类别
+     *
+     * @param category 类别信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增用户类别", module = "用户类别管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody UserCategory category) {
+        userCategoryService.addCategory(category);
+        return Result.success();
+    }
+
+    /**
+     * 修改用户类别
+     *
+     * @param category 类别信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改用户类别", module = "用户类别管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody UserCategory category) {
+        userCategoryService.updateCategory(category);
+        return Result.success();
+    }
+
+    /**
+     * 删除用户类别(内置类别和已被使用的类别不可删除)
+     *
+     * @param id 类别ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除用户类别", module = "用户类别管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        userCategoryService.deleteCategory(id);
+        return Result.success();
+    }
+
+    /**
+     * 修改用户类别状态(启用/停用)
+     *
+     * @param id     类别ID
+     * @param status 目标状态:1=启用,0=停用
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/status")
+    @Log(value = "修改用户类别状态", module = "用户类别管理", operationType = OperationType.UPDATE)
+    public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
+        userCategoryService.updateStatus(id, status);
+        return Result.success();
+    }
+}

+ 16 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ArticleRejectDTO.java

@@ -0,0 +1,16 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 文章审核拒绝 DTO
+ */
+@Data
+public class ArticleRejectDTO {
+
+    /** 拒绝原因 */
+    @NotBlank(message = "拒绝原因不能为空")
+    private String reason;
+}

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ContentArticleQueryDTO.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 内容文章查询条件 DTO
+ */
+@Data
+public class ContentArticleQueryDTO {
+
+    /** 标题模糊搜索 */
+    private String title;
+
+    /** 作者模糊搜索 */
+    private String author;
+
+    /**
+     * 状态:DRAFT=草稿,PENDING=待审核,PUBLISHED=已发布,
+     * REJECTED=已拒绝,OFFLINE=已下架
+     */
+    private String status;
+
+    /** 创建人ID(用于只看自己的) */
+    private Long createBy;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 9 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java

@@ -23,6 +23,15 @@ public class DeviceQueryDTO {
     /** 固件版本(精确匹配,null=全部) */
     private String firmwareVersion;
 
+    /** 省级行政区划编码(精确匹配) */
+    private String provinceCode;
+
+    /** 市级行政区划编码(精确匹配) */
+    private String cityCode;
+
+    /** 区/县级行政区划编码(精确匹配) */
+    private String districtCode;
+
     /** 当前页码(从1开始) */
     private Integer pageNum = 1;
 

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java

@@ -31,6 +31,18 @@ public class DeviceSaveDTO {
     /** 备注(选填,最长200字符) */
     private String remark;
 
+    /** 省级行政区划编码(选填) */
+    private String provinceCode;
+
+    /** 市级行政区划编码(选填) */
+    private String cityCode;
+
+    /** 区/县级行政区划编码(选填) */
+    private String districtCode;
+
+    /** 详细地址(选填,最长200字符) */
+    private String address;
+
     /**
      * 绑定用户ID列表(user_profile.id,选填)
      * 新增:绑定初始用户;编辑:与当前绑定用户做差集合并(null 表示不变更绑定关系,空列表表示清空所有绑定)

+ 22 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserCategoryQueryDTO.java

@@ -0,0 +1,22 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 用户类别分页查询参数 DTO
+ */
+@Data
+public class UserCategoryQueryDTO {
+
+    /** 关键字(名称/备注模糊查询) */
+    private String keyword;
+
+    /** 状态:1=启用,0=停用 */
+    private Integer status;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 15 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java

@@ -38,6 +38,21 @@ public class UserProfileDTO {
     @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
     private String phone;
 
+    /** 用户类型:1=普通用户,2=专家,3=管理员(选填,缺省按普通用户) */
+    private Integer userType;
+
+    /** 省级行政区划编码(GB/T 2260) */
+    private String provinceCode;
+
+    /** 市级行政区划编码 */
+    private String cityCode;
+
+    /** 区/县级行政区划编码 */
+    private String districtCode;
+
+    /** 详细地址 */
+    private String address;
+
     /** 肩宽(cm,必填) */
     @NotNull(message = "肩宽不能为空")
     private BigDecimal shoulderWidth;

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileQueryDTO.java

@@ -17,6 +17,18 @@ public class UserProfileQueryDTO {
     /** 性别:1=男,2=女,不传则查全部 */
     private Integer gender;
 
+    /** 用户类型:1=普通用户,2=专家,3=管理员,不传则查全部 */
+    private Integer userType;
+
+    /** 省级行政区划编码(选填,可独立查询) */
+    private String provinceCode;
+
+    /** 市级行政区划编码(选填,可独立查询) */
+    private String cityCode;
+
+    /** 区/县级行政区划编码(选填,可独立查询) */
+    private String districtCode;
+
     /** 注册时间开始(格式:yyyy-MM-dd HH:mm:ss) */
     private String startTime;
 

+ 74 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/ContentArticle.java

@@ -0,0 +1,74 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 内容文章实体
+ * 对应数据库 content_article 表
+ * 状态流转:DRAFT(草稿)→ PENDING(待审核)→ PUBLISHED(已发布)
+ *          PENDING → REJECTED(已拒绝)
+ *          PUBLISHED → OFFLINE(已下架)→ PUBLISHED(重新上架)
+ */
+@Data
+@TableName("content_article")
+public class ContentArticle {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 文章标题 */
+    private String title;
+
+    /** 富文本内容(含HTML标签、图片) */
+    private String content;
+
+    /** 封面图URL */
+    private String coverImage;
+
+    /** 作者名称 */
+    private String author;
+
+    /** 文章摘要 */
+    private String summary;
+
+    /**
+     * 状态:DRAFT=草稿,PENDING=待审核,PUBLISHED=已发布,
+     * REJECTED=已拒绝,OFFLINE=已下架
+     */
+    private String status;
+
+    /** 拒绝原因(被拒绝时记录) */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private String rejectReason;
+
+    /** 浏览次数 */
+    private Integer viewCount;
+
+    /** 发布时间 */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private LocalDateTime publishTime;
+
+    /** 创建人ID(对应 app_user.id) */
+    @TableField(fill = FieldFill.INSERT)
+    private Long createBy;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新人ID(对应 app_user.id) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Long updateBy;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java

@@ -39,6 +39,18 @@ public class Device {
     /** 最后在线时间 */
     private LocalDateTime lastOnlineTime;
 
+    /** 省级行政区划编码(GB/T 2260) */
+    private String provinceCode;
+
+    /** 市级行政区划编码 */
+    private String cityCode;
+
+    /** 区/县级行政区划编码 */
+    private String districtCode;
+
+    /** 详细地址 */
+    private String address;
+
     /** 备注 */
     private String remark;
 

+ 53 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserCategory.java

@@ -0,0 +1,53 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户类别实体(用户类型字典)
+ * 对应数据库 user_category 表
+ * code 字段与 app_user.user_type / user_profile.user_type 关联
+ */
+@Data
+@TableName("user_category")
+public class UserCategory {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 类别编码(与 user_type 字段对应,唯一) */
+    private Integer code;
+
+    /** 类别名称 */
+    private String name;
+
+    /** 备注说明 */
+    private String remark;
+
+    /** 前端标签颜色:primary/success/warning/danger/info */
+    private String tagType;
+
+    /** 排序(升序) */
+    private Integer sort;
+
+    /** 是否内置:1=内置(不可删除/改code),0=自定义 */
+    private Integer builtin;
+
+    /** 状态:1=启用,0=停用 */
+    private Integer status;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 15 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserProfile.java

@@ -33,6 +33,21 @@ public class UserProfile {
     /** 手机号(11位,唯一) */
     private String phone;
 
+    /** 用户类型:1=普通用户,2=专家,3=管理员 */
+    private Integer userType;
+
+    /** 省级行政区划编码(GB/T 2260) */
+    private String provinceCode;
+
+    /** 市级行政区划编码 */
+    private String cityCode;
+
+    /** 区/县级行政区划编码 */
+    private String districtCode;
+
+    /** 详细地址 */
+    private String address;
+
     /** 肩宽(cm) */
     private BigDecimal shoulderWidth;
 

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/ContentArticleMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 内容文章 Mapper 接口
+ */
+@Mapper
+public interface ContentArticleMapper extends BaseMapper<ContentArticle> {
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/UserCategoryMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.UserCategory;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 用户类别 Mapper 接口
+ */
+@Mapper
+public interface UserCategoryMapper extends BaseMapper<UserCategory> {
+}

+ 8 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AcupointService.java

@@ -21,6 +21,14 @@ public interface AcupointService extends IService<Acupoint> {
      */
     IPage<Acupoint> pageList(AcupointQueryDTO queryDTO);
 
+    /**
+     * 查询全部穴位(用于 Excel 导出,不分页)
+     *
+     * @param queryDTO 查询条件
+     * @return 穴位列表
+     */
+    List<Acupoint> listAll(AcupointQueryDTO queryDTO);
+
     /**
      * 获取可用于穴位Y轴计算的医学定位术语选项
      *

+ 78 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/ContentArticleService.java

@@ -0,0 +1,78 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.ArticleRejectDTO;
+import com.aijiuyi.admin.controller.dto.ContentArticleQueryDTO;
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+/**
+ * 内容文章 Service 接口
+ */
+public interface ContentArticleService extends IService<ContentArticle> {
+
+    /**
+     * 分页查询文章列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<ContentArticle> pageList(ContentArticleQueryDTO queryDTO);
+
+    /**
+     * 新建文章(初始状态为草稿)
+     *
+     * @param article 文章内容
+     */
+    void addArticle(ContentArticle article);
+
+    /**
+     * 编辑文章(仅草稿/已拒绝状态可编辑,系统管理员可编辑任意状态)
+     *
+     * @param article 文章内容
+     */
+    void updateArticle(ContentArticle article);
+
+    /**
+     * 逻辑删除文章(仅系统管理员可操作)
+     *
+     * @param id 文章ID
+     */
+    void deleteArticle(Long id);
+
+    /**
+     * 提交审核(草稿/已拒绝 → 待审核)
+     *
+     * @param id 文章ID
+     */
+    void submitForReview(Long id);
+
+    /**
+     * 审核通过并发布(待审核 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    void publish(Long id);
+
+    /**
+     * 拒绝审核(待审核 → 已拒绝,仅系统管理员)
+     *
+     * @param id         文章ID
+     * @param rejectDTO  拒绝原因
+     */
+    void reject(Long id, ArticleRejectDTO rejectDTO);
+
+    /**
+     * 下架(已发布 → 已下架,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    void offline(Long id);
+
+    /**
+     * 重新上架(已下架 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    void online(Long id);
+}

+ 59 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/UserCategoryService.java

@@ -0,0 +1,59 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.UserCategoryQueryDTO;
+import com.aijiuyi.admin.entity.UserCategory;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 用户类别 Service 接口
+ */
+public interface UserCategoryService extends IService<UserCategory> {
+
+    /**
+     * 分页查询用户类别
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<UserCategory> pageList(UserCategoryQueryDTO queryDTO);
+
+    /**
+     * 查询所有启用的用户类别(用于前端下拉选择)
+     *
+     * @return 启用的用户类别列表(按 sort 升序)
+     */
+    List<UserCategory> listEnabled();
+
+    /**
+     * 新增用户类别
+     *
+     * @param category 类别信息
+     */
+    void addCategory(UserCategory category);
+
+    /**
+     * 修改用户类别
+     *
+     * @param category 类别信息
+     */
+    void updateCategory(UserCategory category);
+
+    /**
+     * 删除用户类别(逻辑删除)
+     * 内置类别和已被用户使用的类别不可删除
+     *
+     * @param id 类别ID
+     */
+    void deleteCategory(Long id);
+
+    /**
+     * 修改用户类别状态(启用/停用)
+     *
+     * @param id     类别ID
+     * @param status 目标状态:1=启用,0=停用
+     */
+    void updateStatus(Long id, Integer status);
+}

+ 10 - 1
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java

@@ -43,6 +43,15 @@ public class AcupointServiceImpl extends ServiceImpl<AcupointMapper, Acupoint> i
     @Override
     public IPage<Acupoint> pageList(AcupointQueryDTO queryDTO) {
         Page<Acupoint> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        return page(page, buildQueryWrapper(queryDTO));
+    }
+
+    @Override
+    public List<Acupoint> listAll(AcupointQueryDTO queryDTO) {
+        return list(buildQueryWrapper(queryDTO));
+    }
+
+    private LambdaQueryWrapper<Acupoint> buildQueryWrapper(AcupointQueryDTO queryDTO) {
         LambdaQueryWrapper<Acupoint> wrapper = new LambdaQueryWrapper<>();
         wrapper.like(StringUtils.hasText(queryDTO.getName()), Acupoint::getName, queryDTO.getName())
                .eq(StringUtils.hasText(queryDTO.getMeridian()), Acupoint::getMeridian, queryDTO.getMeridian())
@@ -71,7 +80,7 @@ public class AcupointServiceImpl extends ServiceImpl<AcupointMapper, Acupoint> i
                    w.eq(Acupoint::getParseStatus, parseStatus);
                })
                .orderByDesc(Acupoint::getCreateTime);
-        return page(page, wrapper);
+        return wrapper;
     }
 
     @Override

+ 280 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/ContentArticleServiceImpl.java

@@ -0,0 +1,280 @@
+package com.aijiuyi.admin.service.impl;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.context.RequestContext;
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.ArticleRejectDTO;
+import com.aijiuyi.admin.controller.dto.ContentArticleQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.aijiuyi.admin.mapper.ContentArticleMapper;
+import com.aijiuyi.admin.service.AppUserService;
+import com.aijiuyi.admin.service.ContentArticleService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+
+/**
+ * 内容文章 Service 实现
+ * 权限模型:
+ *   - 普通管理员(adminRole=2/3):可创建、编辑自己的草稿/已拒绝文章,可提交审核
+ *   - 系统管理员(adminRole=1):可编辑任意文章,可审核/发布/下架/上架/物理删除
+ */
+@Service
+public class ContentArticleServiceImpl extends ServiceImpl<ContentArticleMapper, ContentArticle>
+        implements ContentArticleService {
+
+    /** 状态常量 */
+    public static final String STATUS_DRAFT = "DRAFT";
+    public static final String STATUS_PENDING = "PENDING";
+    public static final String STATUS_PUBLISHED = "PUBLISHED";
+    public static final String STATUS_REJECTED = "REJECTED";
+    public static final String STATUS_OFFLINE = "OFFLINE";
+
+    @Autowired
+    private AppUserService appUserService;
+
+    /**
+     * 分页查询文章列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @Override
+    public IPage<ContentArticle> pageList(ContentArticleQueryDTO queryDTO) {
+        Page<ContentArticle> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        LambdaQueryWrapper<ContentArticle> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(StringUtils.hasText(queryDTO.getTitle()), ContentArticle::getTitle, queryDTO.getTitle())
+               .like(StringUtils.hasText(queryDTO.getAuthor()), ContentArticle::getAuthor, queryDTO.getAuthor())
+               .eq(StringUtils.hasText(queryDTO.getStatus()), ContentArticle::getStatus, queryDTO.getStatus())
+               .eq(queryDTO.getCreateBy() != null, ContentArticle::getCreateBy, queryDTO.getCreateBy())
+               .orderByDesc(ContentArticle::getCreateTime);
+        return page(page, wrapper);
+    }
+
+    /**
+     * 新建文章(初始状态为草稿)
+     *
+     * @param article 文章内容
+     */
+    @Override
+    public void addArticle(ContentArticle article) {
+        article.setId(null);
+        // 新建文章默认草稿状态
+        article.setStatus(STATUS_DRAFT);
+        article.setViewCount(0);
+        article.setRejectReason(null);
+        article.setPublishTime(null);
+        save(article);
+        LogUtil.info(ContentArticleServiceImpl.class, "新建文章[{}],标题:{}", article.getId(), article.getTitle());
+    }
+
+    /**
+     * 编辑文章(仅草稿/已拒绝/已下架状态可编辑;系统管理员可编辑任意非已发布状态)
+     *
+     * @param article 文章内容
+     */
+    @Override
+    public void updateArticle(ContentArticle article) {
+        ContentArticle exist = getExistArticle(article.getId());
+        checkUpdatePermission(exist);
+        // 不允许前端直接修改状态、拒绝原因、发布时间、浏览量
+        article.setStatus(null);
+        article.setRejectReason(null);
+        article.setPublishTime(null);
+        article.setViewCount(null);
+        updateById(article);
+        LogUtil.info(ContentArticleServiceImpl.class, "编辑文章[{}]", article.getId());
+    }
+
+    /**
+     * 逻辑删除文章(仅系统管理员可操作)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void deleteArticle(Long id) {
+        getExistArticle(id);
+        requireSuperAdmin();
+        removeById(id);
+        LogUtil.info(ContentArticleServiceImpl.class, "删除文章[{}]", id);
+    }
+
+    /**
+     * 提交审核(草稿/已拒绝 → 待审核)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void submitForReview(Long id) {
+        ContentArticle exist = getExistArticle(id);
+        // 只有作者本人或系统管理员可提交
+        if (!isSuperAdmin() && !exist.getCreateBy().equals(RequestContext.getUserId())) {
+            throw new BusinessException(ResultCode.ARTICLE_PERMISSION_DENIED);
+        }
+        if (!STATUS_DRAFT.equals(exist.getStatus()) && !STATUS_REJECTED.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_PENDING);
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]提交审核", id);
+    }
+
+    /**
+     * 审核通过并发布(待审核 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void publish(Long id) {
+        requireSuperAdmin();
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_PENDING.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_PUBLISHED);
+        update.setPublishTime(LocalDateTime.now());
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]审核通过并发布", id);
+    }
+
+    /**
+     * 拒绝审核(待审核 → 已拒绝,仅系统管理员)
+     *
+     * @param id        文章ID
+     * @param rejectDTO 拒绝原因
+     */
+    @Override
+    public void reject(Long id, ArticleRejectDTO rejectDTO) {
+        requireSuperAdmin();
+        if (rejectDTO == null || !StringUtils.hasText(rejectDTO.getReason())) {
+            throw new BusinessException(ResultCode.ARTICLE_REJECT_REASON_REQUIRED);
+        }
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_PENDING.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_REJECTED);
+        update.setRejectReason(rejectDTO.getReason());
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]审核拒绝,原因:{}", id, rejectDTO.getReason());
+    }
+
+    /**
+     * 下架(已发布 → 已下架,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void offline(Long id) {
+        requireSuperAdmin();
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_PUBLISHED.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_OFFLINE);
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]下架", id);
+    }
+
+    /**
+     * 重新上架(已下架 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void online(Long id) {
+        requireSuperAdmin();
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_OFFLINE.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_PUBLISHED);
+        // 首次发布时间已存在则保留,否则补发当前时间
+        if (exist.getPublishTime() == null) {
+            update.setPublishTime(LocalDateTime.now());
+        }
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]重新上架", id);
+    }
+
+    /**
+     * 查询文章,不存在则抛出异常
+     *
+     * @param id 文章ID
+     * @return 文章对象
+     */
+    private ContentArticle getExistArticle(Long id) {
+        ContentArticle exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException(ResultCode.ARTICLE_NOT_FOUND);
+        }
+        return exist;
+    }
+
+    /**
+     * 校验编辑权限:
+     *   - 系统管理员可编辑任意非已发布状态
+     *   - 普通管理员仅可编辑自己的草稿/已拒绝文章
+     *
+     * @param exist 现有文章
+     */
+    private void checkUpdatePermission(ContentArticle exist) {
+        boolean superAdmin = isSuperAdmin();
+        if (superAdmin) {
+            // 已发布的文章必须先下架才能编辑
+            if (STATUS_PUBLISHED.equals(exist.getStatus())) {
+                throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+            }
+            return;
+        }
+        // 普通管理员仅可编辑自己的文章
+        Long userId = RequestContext.getUserId();
+        if (userId == null || !userId.equals(exist.getCreateBy())) {
+            throw new BusinessException(ResultCode.ARTICLE_PERMISSION_DENIED);
+        }
+        if (!STATUS_DRAFT.equals(exist.getStatus()) && !STATUS_REJECTED.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+    }
+
+    /**
+     * 要求当前用户必须为系统管理员(adminRole=1)
+     */
+    private void requireSuperAdmin() {
+        if (!isSuperAdmin()) {
+            throw new BusinessException(ResultCode.ARTICLE_PERMISSION_DENIED);
+        }
+    }
+
+    /**
+     * 判断当前登录用户是否为系统管理员(adminRole=1)
+     *
+     * @return true=系统管理员
+     */
+    private boolean isSuperAdmin() {
+        Long userId = RequestContext.getUserId();
+        if (userId == null) {
+            return false;
+        }
+        AppUser user = appUserService.getById(userId);
+        return user != null && user.getAdminRole() != null && user.getAdminRole() == 1;
+    }
+}

+ 4 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/DeviceServiceImpl.java

@@ -303,6 +303,10 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         device.setDeviceModel(dto.getDeviceModel());
         device.setSerialNo(dto.getSerialNo());
         device.setFirmwareVersion(dto.getFirmwareVersion());
+        device.setProvinceCode(dto.getProvinceCode());
+        device.setCityCode(dto.getCityCode());
+        device.setDistrictCode(dto.getDistrictCode());
+        device.setAddress(dto.getAddress());
         device.setRemark(dto.getRemark());
         device.setOnlineStatus(0);
         return device;

+ 167 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserCategoryServiceImpl.java

@@ -0,0 +1,167 @@
+package com.aijiuyi.admin.service.impl;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.UserCategoryQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.entity.UserCategory;
+import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.mapper.AppUserMapper;
+import com.aijiuyi.admin.mapper.UserCategoryMapper;
+import com.aijiuyi.admin.mapper.UserProfileMapper;
+import com.aijiuyi.admin.service.UserCategoryService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+/**
+ * 用户类别 Service 实现类
+ */
+@Service
+public class UserCategoryServiceImpl extends ServiceImpl<UserCategoryMapper, UserCategory>
+        implements UserCategoryService {
+
+    @Autowired
+    private AppUserMapper appUserMapper;
+
+    @Autowired
+    private UserProfileMapper userProfileMapper;
+
+    @Override
+    public IPage<UserCategory> pageList(UserCategoryQueryDTO queryDTO) {
+        Page<UserCategory> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        LambdaQueryWrapper<UserCategory> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.hasText(queryDTO.getKeyword())) {
+            String kw = queryDTO.getKeyword();
+            wrapper.and(w -> w.like(UserCategory::getName, kw).or().like(UserCategory::getRemark, kw));
+        }
+        wrapper.eq(queryDTO.getStatus() != null, UserCategory::getStatus, queryDTO.getStatus())
+               .orderByAsc(UserCategory::getSort)
+               .orderByAsc(UserCategory::getCode);
+        return page(page, wrapper);
+    }
+
+    @Override
+    public List<UserCategory> listEnabled() {
+        return list(new LambdaQueryWrapper<UserCategory>()
+                .eq(UserCategory::getStatus, 1)
+                .orderByAsc(UserCategory::getSort)
+                .orderByAsc(UserCategory::getCode));
+    }
+
+    @Override
+    public void addCategory(UserCategory category) {
+        if (category.getCode() == null) {
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        if (!StringUtils.hasText(category.getName())) {
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        // 编码唯一性
+        long codeCount = lambdaQuery().eq(UserCategory::getCode, category.getCode()).count();
+        if (codeCount > 0) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_CODE_EXISTS);
+        }
+        // 名称唯一性
+        long nameCount = lambdaQuery().eq(UserCategory::getName, category.getName()).count();
+        if (nameCount > 0) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_NAME_EXISTS);
+        }
+        // 自定义类别强制 builtin=0
+        category.setBuiltin(0);
+        if (category.getStatus() == null) {
+            category.setStatus(1);
+        }
+        if (category.getSort() == null) {
+            category.setSort(0);
+        }
+        if (!StringUtils.hasText(category.getTagType())) {
+            category.setTagType("primary");
+        }
+        save(category);
+        LogUtil.info(UserCategoryServiceImpl.class, "新增用户类别 code=[{}] name=[{}]",
+                category.getCode(), category.getName());
+    }
+
+    @Override
+    public void updateCategory(UserCategory category) {
+        if (category.getId() == null) {
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        UserCategory exist = getById(category.getId());
+        if (exist == null) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
+        }
+        // 内置类别不允许修改 code
+        if (Integer.valueOf(1).equals(exist.getBuiltin())
+                && category.getCode() != null
+                && !category.getCode().equals(exist.getCode())) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_BUILTIN_FORBIDDEN);
+        }
+        // 编码唯一性(排除自身)
+        if (category.getCode() != null && !category.getCode().equals(exist.getCode())) {
+            long codeCount = lambdaQuery()
+                    .eq(UserCategory::getCode, category.getCode())
+                    .ne(UserCategory::getId, category.getId())
+                    .count();
+            if (codeCount > 0) {
+                throw new BusinessException(ResultCode.USER_CATEGORY_CODE_EXISTS);
+            }
+        }
+        // 名称唯一性(排除自身)
+        if (StringUtils.hasText(category.getName()) && !category.getName().equals(exist.getName())) {
+            long nameCount = lambdaQuery()
+                    .eq(UserCategory::getName, category.getName())
+                    .ne(UserCategory::getId, category.getId())
+                    .count();
+            if (nameCount > 0) {
+                throw new BusinessException(ResultCode.USER_CATEGORY_NAME_EXISTS);
+            }
+        }
+        // 内置标识不可篡改
+        category.setBuiltin(exist.getBuiltin());
+        updateById(category);
+        LogUtil.info(UserCategoryServiceImpl.class, "修改用户类别[{}]", category.getId());
+    }
+
+    @Override
+    public void deleteCategory(Long id) {
+        UserCategory exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
+        }
+        if (Integer.valueOf(1).equals(exist.getBuiltin())) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_BUILTIN_FORBIDDEN);
+        }
+        // 是否仍被使用
+        long appUserCount = appUserMapper.selectCount(
+                new LambdaQueryWrapper<AppUser>().eq(AppUser::getUserType, exist.getCode()));
+        long profileCount = userProfileMapper.selectCount(
+                new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserType, exist.getCode()));
+        if (appUserCount > 0 || profileCount > 0) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_IN_USE);
+        }
+        removeById(id);
+        LogUtil.info(UserCategoryServiceImpl.class, "删除用户类别[{}] code=[{}]", id, exist.getCode());
+    }
+
+    @Override
+    public void updateStatus(Long id, Integer status) {
+        UserCategory exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
+        }
+        UserCategory update = new UserCategory();
+        update.setId(id);
+        update.setStatus(status);
+        updateById(update);
+        LogUtil.info(UserCategoryServiceImpl.class, "修改用户类别[{}]状态为[{}]", id, status);
+    }
+}

+ 39 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserProfileServiceImpl.java

@@ -5,7 +5,9 @@ import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.UserProfileDTO;
 import com.aijiuyi.admin.controller.dto.UserProfileQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
 import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.mapper.AppUserMapper;
 import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.UserAcupointService;
 import com.aijiuyi.admin.service.UserProfileService;
@@ -31,6 +33,9 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
     @Autowired
     private UserAcupointService userAcupointService;
 
+    @Autowired
+    private AppUserMapper appUserMapper;
+
     @Override
     public IPage<UserProfile> pageList(UserProfileQueryDTO queryDTO) {
         Page<UserProfile> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
@@ -47,6 +52,8 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         save(profile);
         LogUtil.info(UserProfileServiceImpl.class, "新增子用户[{}],手机号[{}]", dto.getName(), dto.getPhone());
 
+        syncAppUserType(dto.getPhone(), dto.getUserType());
+
         regenerateUserAcupoints(profile.getId(), "新增");
     }
 
@@ -69,6 +76,10 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         updateById(profile);
         LogUtil.info(UserProfileServiceImpl.class, "修改子用户[{}]", dto.getId());
 
+        // 优先以传入手机号同步;未传入时回退到已存记录的手机号
+        String syncPhone = StringUtils.hasText(dto.getPhone()) ? dto.getPhone() : exist.getPhone();
+        syncAppUserType(syncPhone, dto.getUserType());
+
         regenerateUserAcupoints(dto.getId(), "修改");
     }
 
@@ -176,6 +187,29 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         }
     }
 
+    /**
+     * 同步用户类型到 app_user:若 user_profile.phone 与 app_user.phone 一致,同步修改其 userType。
+     */
+    private void syncAppUserType(String phone, Integer userType) {
+        if (!StringUtils.hasText(phone) || userType == null) {
+            return;
+        }
+        AppUser appUser = appUserMapper.selectOne(
+                new LambdaQueryWrapper<AppUser>().eq(AppUser::getPhone, phone));
+        if (appUser == null) {
+            return;
+        }
+        if (userType.equals(appUser.getUserType())) {
+            return;
+        }
+        AppUser update = new AppUser();
+        update.setId(appUser.getId());
+        update.setUserType(userType);
+        appUserMapper.updateById(update);
+        LogUtil.info(UserProfileServiceImpl.class,
+                "同步 app_user[{}] 用户类型为[{}](手机号[{}])", appUser.getId(), userType, phone);
+    }
+
     private UserProfile buildProfileFromDto(UserProfileDTO dto) {
         UserProfile profile = new UserProfile();
         profile.setUserId(dto.getUserId());
@@ -183,6 +217,11 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         profile.setGender(dto.getGender());
         profile.setAge(dto.getAge());
         profile.setPhone(dto.getPhone());
+        profile.setUserType(dto.getUserType());
+        profile.setProvinceCode(dto.getProvinceCode());
+        profile.setCityCode(dto.getCityCode());
+        profile.setDistrictCode(dto.getDistrictCode());
+        profile.setAddress(dto.getAddress());
         profile.setShoulderWidth(dto.getShoulderWidth());
         profile.setBodyHeight(dto.getBodyHeight());
         profile.setSpineLength(dto.getSpineLength());

+ 23 - 3
code/backend/src/main/resources/application-dev.yml

@@ -3,7 +3,7 @@ spring:
   datasource:
     url: jdbc:mysql://localhost:3306/aijiuyi_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false
     username: root
-    password: jpDb123$%^
+    password: lilishop
     driver-class-name: com.mysql.cj.jdbc.Driver
     hikari:
       # 最大连接池大小
@@ -15,10 +15,10 @@ spring:
 
   # ==================== Redis 配置 ====================
   redis:
-    host: 39.107.191.207
+    host: 127.0.0.1
     port: 6379
     # 密码(无密码留空,有密码填写)
-    password: bwy123$%^
+    password: lilishop
     database: 0
     # 连接超时时间
     timeout: 10000ms
@@ -33,6 +33,26 @@ spring:
         # 最小空闲连接
         min-idle: 0
 
+# ==================== 阿里云 OSS 配置(内容管理图片/视频上传) ====================
+aliyun:
+  oss:
+    # OSS 服务的 Endpoint(华北2-北京)
+    endpoint: https://oss-cn-beijing.aliyuncs.com
+    # AccessKey ID
+    access-key-id: LTAI5t7NZAFYFAcmh68MvFLK
+    # AccessKey Secret
+    access-key-secret: hEs6C02BCKycfoq5AzHvFSb3KAMIyb
+    # 存储桶名称
+    bucket-name: ajy001
+    # 访问域名(用于拼接文件公网访问 URL)
+    domain: https://ajy001.oss-cn-beijing.aliyuncs.com
+    # 文件在 OSS 中的根目录
+    base-dir: content
+    # 图片最大大小(字节,默认 5MB)
+    max-image-size: 5242880
+    # 视频最大大小(字节,默认 50MB)
+    max-video-size: 52428800
+
 # ==================== 日志配置(开发环境) ====================
 logging:
   level:

+ 22 - 0
code/backend/src/main/resources/application-test.yml

@@ -33,6 +33,28 @@ spring:
         # 最小空闲连接
         min-idle: 0
 
+
+# ==================== 阿里云 OSS 配置(内容管理图片/视频上传) ====================
+aliyun:
+  oss:
+    # OSS 服务的 Endpoint(华北2-北京)
+    endpoint: https://oss-cn-beijing.aliyuncs.com
+    # AccessKey ID
+    access-key-id: LTAI5t7NZAFYFAcmh68MvFLK
+    # AccessKey Secret
+    access-key-secret: hEs6C02BCKycfoq5AzHvFSb3KAMIyb
+    # 存储桶名称
+    bucket-name: ajy001
+    # 访问域名(用于拼接文件公网访问 URL)
+    domain: https://ajy001.oss-cn-beijing.aliyuncs.com
+    # 文件在 OSS 中的根目录
+    base-dir: content
+    # 图片最大大小(字节,默认 5MB)
+    max-image-size: 5242880
+    # 视频最大大小(字节,默认 50MB)
+    max-video-size: 52428800
+
+
 # ==================== 日志配置(开发环境) ====================
 logging:
   level:

+ 6 - 0
code/backend/src/main/resources/application.yml

@@ -8,6 +8,12 @@ spring:
     time-zone: Asia/Shanghai
     default-property-inclusion: non_null
 
+  # 文件上传大小限制(内容管理视频上传用)
+  servlet:
+    multipart:
+      max-file-size: 100MB
+      max-request-size: 100MB
+
 server:
   port: 18888
   servlet:

+ 14 - 1
code/backend/src/main/resources/mapper/DeviceMapper.xml

@@ -18,12 +18,16 @@
             d.firmware_version,
             d.online_status,
             d.last_online_time,
+            d.province_code,
+            d.city_code,
+            d.district_code,
+            d.address,
             d.remark,
             d.deleted,
             d.create_time,
             d.update_time,
             COUNT(DISTINCT ud.user_id) AS bound_user_count,
-            GROUP_CONCAT(DISTINCT up.name ORDER BY up.name SEPARATOR '、') AS bound_user_names
+            GROUP_CONCAT(DISTINCT up.name ORDER BY ud.bind_time DESC SEPARATOR '、') AS bound_user_names
         FROM device d
         LEFT JOIN user_device ud ON ud.device_code = d.device_code
             AND ud.deleted = 0
@@ -43,6 +47,15 @@
         <if test="q.firmwareVersion != null and q.firmwareVersion != ''">
             AND d.firmware_version = #{q.firmwareVersion}
         </if>
+        <if test="q.provinceCode != null and q.provinceCode != ''">
+            AND d.province_code = #{q.provinceCode}
+        </if>
+        <if test="q.cityCode != null and q.cityCode != ''">
+            AND d.city_code = #{q.cityCode}
+        </if>
+        <if test="q.districtCode != null and q.districtCode != ''">
+            AND d.district_code = #{q.districtCode}
+        </if>
         <if test="q.boundUserName != null and q.boundUserName != ''">
             AND d.device_code IN (
                 SELECT ud2.device_code FROM user_device ud2

+ 17 - 0
code/backend/src/main/resources/mapper/UserProfileMapper.xml

@@ -15,6 +15,11 @@
             up.gender,
             up.age,
             up.phone,
+            up.user_type,
+            up.province_code,
+            up.city_code,
+            up.district_code,
+            up.address,
             up.shoulder_width,
             up.body_height,
             up.spine_length,
@@ -38,6 +43,18 @@
         <if test="q.gender != null">
             AND up.gender = #{q.gender}
         </if>
+        <if test="q.userType != null">
+            AND up.user_type = #{q.userType}
+        </if>
+        <if test="q.provinceCode != null and q.provinceCode != ''">
+            AND up.province_code = #{q.provinceCode}
+        </if>
+        <if test="q.cityCode != null and q.cityCode != ''">
+            AND up.city_code = #{q.cityCode}
+        </if>
+        <if test="q.districtCode != null and q.districtCode != ''">
+            AND up.district_code = #{q.districtCode}
+        </if>
         <if test="q.startTime != null and q.startTime != ''">
             AND up.create_time &gt;= #{q.startTime}
         </if>

+ 33 - 0
code/backend/src/main/resources/sql/init.sql

@@ -56,6 +56,34 @@ CREATE TABLE IF NOT EXISTS `app_user` (
 INSERT INTO `app_user` (`username`, `password`, `nickname`, `user_type`, `status`, `deleted`, `create_time`, `update_time`)
 VALUES ('admin', MD5('admin123'), '超级管理员', 3, 1, 0, NOW(), NOW());
 
+-- ----------------------------
+-- 用户类别表(用户类型字典,code 与 app_user.user_type / user_profile.user_type 关联)
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `user_category` (
+    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `code`        INT          NOT NULL COMMENT '类别编码(与 user_type 字段对应,唯一)',
+    `name`        VARCHAR(50)  NOT NULL COMMENT '类别名称',
+    `remark`      VARCHAR(255)          COMMENT '备注说明',
+    `tag_type`    VARCHAR(20)  DEFAULT 'primary' COMMENT '前端标签颜色:primary/success/warning/danger/info',
+    `sort`        INT          NOT NULL DEFAULT 0 COMMENT '排序(升序)',
+    `builtin`     TINYINT      NOT NULL DEFAULT 0 COMMENT '是否内置:1=内置(不可删除/改code),0=自定义',
+    `status`      TINYINT      NOT NULL DEFAULT 1 COMMENT '状态:1=启用,0=停用',
+    `deleted`     TINYINT      NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    `create_time` DATETIME              COMMENT '创建时间',
+    `update_time` DATETIME              COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_code` (`code`)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COMMENT = '用户类别表';
+
+-- 初始化内置用户类别(与原 userType 取值保持一致)
+INSERT INTO `user_category` (`code`, `name`, `remark`, `tag_type`, `sort`, `builtin`, `status`, `deleted`, `create_time`, `update_time`)
+VALUES
+    (1, '普通用户', '默认用户类别', 'primary', 1, 1, 1, 0, NOW(), NOW()),
+    (2, '专家',     '可创作方案的专家用户', 'warning', 2, 1, 1, 0, NOW(), NOW()),
+    (3, '管理员',   '后台管理员(仅 code=3 可登录后台)', 'danger', 3, 1, 1, 0, NOW(), NOW());
+
 -- ----------------------------
 -- 子用户信息表(属于某个应用用户)
 -- ----------------------------
@@ -66,6 +94,11 @@ CREATE TABLE IF NOT EXISTS `user_profile` (
     `gender`            TINYINT               COMMENT '性别:1=男,2=女',
     `age`               INT                   COMMENT '年龄',
     `phone`             VARCHAR(20)           COMMENT '手机号(唯一)',
+    `user_type`         TINYINT      NOT NULL DEFAULT 1 COMMENT '用户类型:1=普通用户,2=专家,3=管理员',
+    `province_code`     VARCHAR(12)           COMMENT '省级行政区划编码(GB/T 2260)',
+    `city_code`         VARCHAR(12)           COMMENT '市级行政区划编码',
+    `district_code`     VARCHAR(12)           COMMENT '区/县级行政区划编码',
+    `address`           VARCHAR(255)          COMMENT '详细地址',
     `shoulder_width`    DECIMAL(5,1)          COMMENT '肩宽(cm)',
     `body_height`       DECIMAL(5,1)          COMMENT '身长/坐高(cm)',
     `spine_length`      DECIMAL(5,1)          COMMENT '脊柱长度C7-S4(cm,范围30-60)',

+ 33 - 0
code/backend/src/main/resources/sql/migration_content_article.sql

@@ -0,0 +1,33 @@
+-- ==================== 内容管理模块建表 SQL ====================
+-- 执行数据库:aijiuyi_admin
+-- 作用:创建文章内容表(content_article),支持草稿/待审核/已发布/已拒绝/已下架状态流转
+-- 管理员在 app_user 表中,通过 admin_role 字段区分权限:1=超级管理员(可审核),2/3=普通管理员(可创建编辑)
+
+SET NAMES utf8mb4;
+
+-- ----------------------------
+-- 内容文章表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `content_article` (
+    `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `title`          VARCHAR(200)  NOT NULL COMMENT '文章标题',
+    `content`        LONGTEXT               COMMENT '富文本内容(含HTML标签、图片、视频)',
+    `cover_image`    VARCHAR(500)           COMMENT '封面图URL',
+    `author`         VARCHAR(50)            COMMENT '作者名称',
+    `summary`        VARCHAR(500)           COMMENT '文章摘要',
+    `status`         VARCHAR(20)   NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT=草稿,PENDING=待审核,PUBLISHED=已发布,REJECTED=已拒绝,OFFLINE=已下架',
+    `reject_reason`  VARCHAR(500)           COMMENT '审核拒绝原因',
+    `view_count`     INT           NOT NULL DEFAULT 0 COMMENT '浏览次数',
+    `publish_time`   DATETIME               COMMENT '发布时间',
+    `create_by`      BIGINT                 COMMENT '创建人ID(关联 app_user.id)',
+    `create_time`    DATETIME               COMMENT '创建时间',
+    `update_by`      BIGINT                 COMMENT '更新人ID(关联 app_user.id)',
+    `update_time`    DATETIME               COMMENT '更新时间',
+    `deleted`        TINYINT       NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    PRIMARY KEY (`id`),
+    KEY `idx_status` (`status`),
+    KEY `idx_create_by` (`create_by`),
+    KEY `idx_create_time` (`create_time`)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COMMENT = '内容文章表';

+ 29 - 0
code/backend/src/main/resources/sql/migration_user_category.sql

@@ -0,0 +1,29 @@
+-- =====================================================
+-- 迁移脚本:新增 user_category 用户类别表(用户类型字典)
+-- 适用:已部署数据库(首次部署使用 init.sql 即可,无需执行此脚本)
+-- =====================================================
+
+CREATE TABLE IF NOT EXISTS `user_category` (
+    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `code`        INT          NOT NULL COMMENT '类别编码(与 user_type 字段对应,唯一)',
+    `name`        VARCHAR(50)  NOT NULL COMMENT '类别名称',
+    `remark`      VARCHAR(255)          COMMENT '备注说明',
+    `tag_type`    VARCHAR(20)  DEFAULT 'primary' COMMENT '前端标签颜色:primary/success/warning/danger/info',
+    `sort`        INT          NOT NULL DEFAULT 0 COMMENT '排序(升序)',
+    `builtin`     TINYINT      NOT NULL DEFAULT 0 COMMENT '是否内置:1=内置(不可删除/改code),0=自定义',
+    `status`      TINYINT      NOT NULL DEFAULT 1 COMMENT '状态:1=启用,0=停用',
+    `deleted`     TINYINT      NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    `create_time` DATETIME              COMMENT '创建时间',
+    `update_time` DATETIME              COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_code` (`code`)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COMMENT = '用户类别表';
+
+-- 初始化内置用户类别(与原 userType 取值保持一致)
+INSERT INTO `user_category` (`code`, `name`, `remark`, `tag_type`, `sort`, `builtin`, `status`, `deleted`, `create_time`, `update_time`)
+VALUES
+    (1, '普通用户', '默认用户类别', 'primary', 1, 1, 1, 0, NOW(), NOW()),
+    (2, '专家',     '可创作方案的专家用户', 'warning', 2, 1, 1, 0, NOW(), NOW()),
+    (3, '管理员',   '后台管理员(仅 code=3 可登录后台)', 'danger', 3, 1, 1, 0, NOW(), NOW());

+ 23 - 0
code/backend/src/main/resources/sql/migration_user_profile_address.sql

@@ -0,0 +1,23 @@
+-- =====================================================
+-- 迁移脚本:user_profile 新增 用户类型 / 省市区行政区划编码 / 详细地址
+-- 适用:已部署数据库(首次部署使用 init.sql 即可,无需执行此脚本)
+-- 执行后将自动同步 user_profile.user_type 与 app_user.user_type(按手机号匹配)
+-- =====================================================
+
+ALTER TABLE `user_profile`
+    ADD COLUMN `user_type`     TINYINT     NOT NULL DEFAULT 1 COMMENT '用户类型:1=普通用户,2=专家,3=管理员' AFTER `phone`,
+    ADD COLUMN `province_code` VARCHAR(12) NULL COMMENT '省级行政区划编码(GB/T 2260)' AFTER `user_type`,
+    ADD COLUMN `city_code`     VARCHAR(12) NULL COMMENT '市级行政区划编码' AFTER `province_code`,
+    ADD COLUMN `district_code` VARCHAR(12) NULL COMMENT '区/县级行政区划编码' AFTER `city_code`,
+    ADD COLUMN `address`       VARCHAR(255) NULL COMMENT '详细地址' AFTER `district_code`;
+
+-- 用 app_user 已有用户类型回填 user_profile(按手机号匹配)
+UPDATE `user_profile` up
+JOIN `app_user` au ON au.phone = up.phone AND au.deleted = 0
+SET up.user_type = au.user_type
+WHERE up.deleted = 0;
+ALTER TABLE device
+    ADD COLUMN province_code VARCHAR(20) DEFAULT '' COMMENT '省级行政区划编码',
+  ADD COLUMN city_code     VARCHAR(20) DEFAULT '' COMMENT '市级行政区划编码',
+  ADD COLUMN district_code VARCHAR(20) DEFAULT '' COMMENT '区/县级行政区划编码',
+  ADD COLUMN address       VARCHAR(200) DEFAULT '' COMMENT '详细地址';

+ 553 - 0
code/frontend/package-lock.json

@@ -9,7 +9,10 @@
       "version": "0.0.0",
       "dependencies": {
         "@element-plus/icons-vue": "^2.3.2",
+        "@wangeditor/editor": "^5.1.23",
+        "@wangeditor/editor-for-vue": "^5.1.12",
         "axios": "^1.14.0",
+        "element-china-area-data": "^6.1.0",
         "element-plus": "^2.13.6",
         "pinia": "^3.0.4",
         "sass": "^1.99.0",
@@ -55,6 +58,15 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/types": {
       "version": "7.29.0",
       "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -751,6 +763,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@transloadit/prettier-bytes": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
+      "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
+      "license": "MIT"
+    },
     "node_modules/@tybys/wasm-util": {
       "version": "0.10.1",
       "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -762,6 +780,12 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/@types/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
+      "license": "MIT"
+    },
     "node_modules/@types/lodash": {
       "version": "4.17.24",
       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
@@ -783,6 +807,61 @@
       "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
       "license": "MIT"
     },
+    "node_modules/@uppy/companion-client": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-2.2.2.tgz",
+      "integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/utils": "^4.1.2",
+        "namespace-emitter": "^2.0.1"
+      }
+    },
+    "node_modules/@uppy/core": {
+      "version": "2.3.4",
+      "resolved": "https://registry.npmjs.org/@uppy/core/-/core-2.3.4.tgz",
+      "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@transloadit/prettier-bytes": "0.0.7",
+        "@uppy/store-default": "^2.1.1",
+        "@uppy/utils": "^4.1.3",
+        "lodash.throttle": "^4.1.1",
+        "mime-match": "^1.0.2",
+        "namespace-emitter": "^2.0.1",
+        "nanoid": "^3.1.25",
+        "preact": "^10.5.13"
+      }
+    },
+    "node_modules/@uppy/store-default": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-2.1.1.tgz",
+      "integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==",
+      "license": "MIT"
+    },
+    "node_modules/@uppy/utils": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-4.1.3.tgz",
+      "integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash.throttle": "^4.1.1"
+      }
+    },
+    "node_modules/@uppy/xhr-upload": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
+      "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/companion-client": "^2.2.2",
+        "@uppy/utils": "^4.1.2",
+        "nanoid": "^3.1.25"
+      },
+      "peerDependencies": {
+        "@uppy/core": "^2.3.3"
+      }
+    },
     "node_modules/@vitejs/plugin-vue": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
@@ -969,6 +1048,165 @@
         "url": "https://github.com/sponsors/antfu"
       }
     },
+    "node_modules/@wangeditor/basic-modules": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
+      "integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-url": "^1.2.4"
+      },
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.throttle": "^4.1.1",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/code-highlight": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
+      "integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
+      "license": "MIT",
+      "dependencies": {
+        "prismjs": "^1.23.0"
+      },
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/core": {
+      "version": "1.1.19",
+      "resolved": "https://registry.npmjs.org/@wangeditor/core/-/core-1.1.19.tgz",
+      "integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/event-emitter": "^0.3.3",
+        "event-emitter": "^0.3.5",
+        "html-void-elements": "^2.0.0",
+        "i18next": "^20.4.0",
+        "scroll-into-view-if-needed": "^2.2.28",
+        "slate-history": "^0.66.0"
+      },
+      "peerDependencies": {
+        "@uppy/core": "^2.1.1",
+        "@uppy/xhr-upload": "^2.0.3",
+        "dom7": "^3.0.0",
+        "is-hotkey": "^0.2.0",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.debounce": "^4.0.8",
+        "lodash.foreach": "^4.5.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "lodash.toarray": "^4.4.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/editor": {
+      "version": "5.1.23",
+      "resolved": "https://registry.npmjs.org/@wangeditor/editor/-/editor-5.1.23.tgz",
+      "integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/core": "^2.1.1",
+        "@uppy/xhr-upload": "^2.0.3",
+        "@wangeditor/basic-modules": "^1.1.7",
+        "@wangeditor/code-highlight": "^1.0.3",
+        "@wangeditor/core": "^1.1.19",
+        "@wangeditor/list-module": "^1.0.5",
+        "@wangeditor/table-module": "^1.1.4",
+        "@wangeditor/upload-image-module": "^1.0.2",
+        "@wangeditor/video-module": "^1.1.4",
+        "dom7": "^3.0.0",
+        "is-hotkey": "^0.2.0",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.debounce": "^4.0.8",
+        "lodash.foreach": "^4.5.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "lodash.toarray": "^4.4.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/editor-for-vue": {
+      "version": "5.1.12",
+      "resolved": "https://registry.npmjs.org/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
+      "integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/editor": ">=5.1.0",
+        "vue": "^3.0.5"
+      }
+    },
+    "node_modules/@wangeditor/list-module": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@wangeditor/list-module/-/list-module-1.0.5.tgz",
+      "integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/table-module": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@wangeditor/table-module/-/table-module-1.1.4.tgz",
+      "integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/upload-image-module": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
+      "integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@uppy/core": "^2.0.3",
+        "@uppy/xhr-upload": "^2.0.3",
+        "@wangeditor/basic-modules": "1.x",
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.foreach": "^4.5.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/video-module": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@wangeditor/video-module/-/video-module-1.1.4.tgz",
+      "integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@uppy/core": "^2.1.4",
+        "@uppy/xhr-upload": "^2.0.7",
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
     "node_modules/adler-32": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
@@ -1036,6 +1274,12 @@
         "node": ">=0.8"
       }
     },
+    "node_modules/china-division": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/china-division/-/china-division-2.7.0.tgz",
+      "integrity": "sha512-4uUPAT+1WfqDh5jytq7omdCmHNk3j+k76zEG/2IqaGcYB90c2SwcixttcypdsZ3T/9tN1TTpBDoeZn+Yw/qBEA==",
+      "license": "MIT"
+    },
     "node_modules/chokidar": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1072,6 +1316,12 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+      "license": "MIT"
+    },
     "node_modules/copy-anything": {
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -1105,6 +1355,19 @@
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
       "license": "MIT"
     },
+    "node_modules/d": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
+      "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
+      "license": "ISC",
+      "dependencies": {
+        "es5-ext": "^0.10.64",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
     "node_modules/dayjs": {
       "version": "1.11.20",
       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
@@ -1130,6 +1393,15 @@
         "node": ">=8"
       }
     },
+    "node_modules/dom7": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/dom7/-/dom7-3.0.0.tgz",
+      "integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
+      "license": "MIT",
+      "dependencies": {
+        "ssr-window": "^3.0.0-alpha.1"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1144,6 +1416,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/element-china-area-data": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/element-china-area-data/-/element-china-area-data-6.1.0.tgz",
+      "integrity": "sha512-IkpcjwQv2A/2AxFiSoaISZ+oMw1rZCPUSOg5sOCwT5jKc96TaawmKZeY81xfxXsO0QbKxU5LLc6AirhG52hUmg==",
+      "license": "MIT",
+      "dependencies": {
+        "china-division": "^2.7.0"
+      }
+    },
     "node_modules/element-plus": {
       "version": "2.13.6",
       "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz",
@@ -1227,12 +1508,86 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es5-ext": {
+      "version": "0.10.64",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
+      "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
+      "hasInstallScript": true,
+      "license": "ISC",
+      "dependencies": {
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.3",
+        "esniff": "^2.0.1",
+        "next-tick": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+      "license": "MIT",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
+    "node_modules/es6-symbol": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
+      "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
+      "license": "ISC",
+      "dependencies": {
+        "d": "^1.0.2",
+        "ext": "^1.7.0"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
+    "node_modules/esniff": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
+      "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
+      "license": "ISC",
+      "dependencies": {
+        "d": "^1.0.1",
+        "es5-ext": "^0.10.62",
+        "event-emitter": "^0.3.5",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
     "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==",
       "license": "MIT"
     },
+    "node_modules/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+      "license": "MIT",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
+    "node_modules/ext": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
+      "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+      "license": "ISC",
+      "dependencies": {
+        "type": "^2.7.2"
+      }
+    },
     "node_modules/fdir": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1414,6 +1769,35 @@
       "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
       "license": "MIT"
     },
+    "node_modules/html-void-elements": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
+      "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/i18next": {
+      "version": "20.6.1",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-20.6.1.tgz",
+      "integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.0"
+      }
+    },
+    "node_modules/immer": {
+      "version": "9.0.21",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+      "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
     "node_modules/immutable": {
       "version": "5.1.5",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -1443,6 +1827,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/is-hotkey": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
+      "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
+      "license": "MIT"
+    },
+    "node_modules/is-plain-object": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-url": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
+      "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
+      "license": "MIT"
+    },
     "node_modules/is-what": {
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -1739,6 +2144,49 @@
         "lodash-es": "*"
       }
     },
+    "node_modules/lodash.camelcase": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.debounce": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+      "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.foreach": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
+      "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+      "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+      "license": "MIT"
+    },
+    "node_modules/lodash.throttle": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+      "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.toarray": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
+      "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
+      "license": "MIT"
+    },
     "node_modules/magic-string": {
       "version": "0.30.21",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1772,6 +2220,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/mime-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
+      "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
+      "license": "ISC",
+      "dependencies": {
+        "wildcard": "^1.1.0"
+      }
+    },
     "node_modules/mime-types": {
       "version": "2.1.35",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
@@ -1790,6 +2247,12 @@
       "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
       "license": "MIT"
     },
+    "node_modules/namespace-emitter": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
+      "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
+      "license": "MIT"
+    },
     "node_modules/nanoid": {
       "version": "3.3.11",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1808,6 +2271,12 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/next-tick": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
+      "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
+      "license": "ISC"
+    },
     "node_modules/node-addon-api": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -1895,6 +2364,25 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/preact": {
+      "version": "10.29.1",
+      "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
+      "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/preact"
+      }
+    },
+    "node_modules/prismjs": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/proxy-from-env": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -1984,6 +2472,47 @@
         "@parcel/watcher": "^2.4.1"
       }
     },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
+    "node_modules/slate": {
+      "version": "0.72.8",
+      "resolved": "https://registry.npmjs.org/slate/-/slate-0.72.8.tgz",
+      "integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
+      "license": "MIT",
+      "dependencies": {
+        "immer": "^9.0.6",
+        "is-plain-object": "^5.0.0",
+        "tiny-warning": "^1.0.3"
+      }
+    },
+    "node_modules/slate-history": {
+      "version": "0.66.0",
+      "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.66.0.tgz",
+      "integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
+      "license": "MIT",
+      "dependencies": {
+        "is-plain-object": "^5.0.0"
+      },
+      "peerDependencies": {
+        "slate": ">=0.65.3"
+      }
+    },
+    "node_modules/snabbdom": {
+      "version": "3.6.3",
+      "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz",
+      "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.17.0"
+      }
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2014,6 +2543,12 @@
         "node": ">=0.8"
       }
     },
+    "node_modules/ssr-window": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-3.0.0.tgz",
+      "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
+      "license": "MIT"
+    },
     "node_modules/superjson": {
       "version": "2.2.6",
       "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@@ -2026,6 +2561,12 @@
         "node": ">=16"
       }
     },
+    "node_modules/tiny-warning": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+      "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+      "license": "MIT"
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.16",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -2051,6 +2592,12 @@
       "license": "0BSD",
       "optional": true
     },
+    "node_modules/type": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
+      "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
+      "license": "ISC"
+    },
     "node_modules/vite": {
       "version": "8.0.7",
       "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
@@ -2177,6 +2724,12 @@
       "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
       "license": "MIT"
     },
+    "node_modules/wildcard": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
+      "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
+      "license": "MIT"
+    },
     "node_modules/wmf": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",

+ 3 - 0
code/frontend/package.json

@@ -10,7 +10,10 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.2",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/editor-for-vue": "^5.1.12",
     "axios": "^1.14.0",
+    "element-china-area-data": "^6.1.0",
     "element-plus": "^2.13.6",
     "pinia": "^3.0.4",
     "sass": "^1.99.0",

+ 8 - 0
code/frontend/src/api/acupoint.js

@@ -28,6 +28,14 @@ export function getAcupointPage(params) {
   return request.get('/acupoint/page', { params })
 }
 
+/**
+ * 导出穴位列表(全量,用于 Excel 导出)
+ * @param {Object} params 查询条件(不含分页参数)
+ */
+export function exportAcupoint(params) {
+  return request.get('/acupoint/export', { params })
+}
+
 /**
  * 查询穴位详情
  * @param {number} id 穴位ID

+ 115 - 0
code/frontend/src/api/content.js

@@ -0,0 +1,115 @@
+import request from '@/utils/request'
+
+/**
+ * 内容管理模块 API
+ * 对应后端 ContentArticleController 和 FileController
+ */
+
+/**
+ * 分页查询文章列表
+ * @param {Object} params 查询参数:title/author/status/createBy/pageNum/pageSize
+ */
+export function getArticlePage(params) {
+  return request.get('/content/articles', { params })
+}
+
+/**
+ * 查询文章详情
+ * @param {number} id 文章ID
+ */
+export function getArticleById(id) {
+  return request.get(`/content/articles/${id}`)
+}
+
+/**
+ * 新建文章
+ * @param {Object} data 文章内容
+ */
+export function addArticle(data) {
+  return request.post('/content/articles', data)
+}
+
+/**
+ * 编辑文章
+ * @param {number} id 文章ID
+ * @param {Object} data 文章内容
+ */
+export function updateArticle(id, data) {
+  return request.put(`/content/articles/${id}`, data)
+}
+
+/**
+ * 删除文章(逻辑删除,仅系统管理员可操作)
+ * @param {number} id 文章ID
+ */
+export function deleteArticle(id) {
+  return request.delete(`/content/articles/${id}`)
+}
+
+/**
+ * 提交文章审核
+ * @param {number} id 文章ID
+ */
+export function submitArticle(id) {
+  return request.post(`/content/articles/${id}/submit`)
+}
+
+/**
+ * 审核通过并发布(仅系统管理员)
+ * @param {number} id 文章ID
+ */
+export function publishArticle(id) {
+  return request.post(`/content/articles/${id}/publish`)
+}
+
+/**
+ * 拒绝审核(仅系统管理员)
+ * @param {number} id 文章ID
+ * @param {string} reason 拒绝原因
+ */
+export function rejectArticle(id, reason) {
+  return request.post(`/content/articles/${id}/reject`, { reason })
+}
+
+/**
+ * 下架文章(仅系统管理员)
+ * @param {number} id 文章ID
+ */
+export function offlineArticle(id) {
+  return request.post(`/content/articles/${id}/offline`)
+}
+
+/**
+ * 重新上架文章(仅系统管理员)
+ * @param {number} id 文章ID
+ */
+export function onlineArticle(id) {
+  return request.post(`/content/articles/${id}/online`)
+}
+
+/**
+ * 上传图片到阿里云 OSS
+ * @param {File} file 图片文件
+ * @returns {Promise<{data:{url:string}}>}
+ */
+export function uploadImage(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request.post('/file/image', formData, {
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+/**
+ * 上传视频到阿里云 OSS
+ * @param {File} file 视频文件
+ * @returns {Promise<{data:{url:string}}>}
+ */
+export function uploadVideo(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request.post('/file/video', formData, {
+    headers: { 'Content-Type': 'multipart/form-data' },
+    timeout: 120000
+  })
+}

+ 6 - 0
code/frontend/src/api/index.js

@@ -31,3 +31,9 @@ export * as adminApi from './admin'
 
 // 方案模拟测试模块
 export * as simulationApi from './simulation'
+
+// 内容管理模块
+export * as contentApi from './content'
+
+// 用户类别管理模块
+export * as userCategoryApi from './userCategory'

+ 62 - 0
code/frontend/src/api/userCategory.js

@@ -0,0 +1,62 @@
+import request from '@/utils/request'
+
+/**
+ * 用户类别管理模块 API
+ * 对应后端 UserCategoryController
+ */
+
+/**
+ * 分页查询用户类别
+ * @param {Object} params 查询参数:keyword/status/pageNum/pageSize
+ */
+export function getUserCategoryPage(params) {
+  return request.get('/user/category/page', { params })
+}
+
+/**
+ * 查询所有启用的用户类别(前端下拉用,无需鉴权依赖于路由配置)
+ */
+export function getUserCategoryList() {
+  return request.get('/user/category/list')
+}
+
+/**
+ * 查询用户类别详情
+ * @param {number} id 类别ID
+ */
+export function getUserCategoryById(id) {
+  return request.get(`/user/category/${id}`)
+}
+
+/**
+ * 新增用户类别
+ * @param {Object} data 类别信息:code/name/remark/tagType/sort/status
+ */
+export function addUserCategory(data) {
+  return request.post('/user/category', data)
+}
+
+/**
+ * 修改用户类别
+ * @param {Object} data 类别信息:id/code/name/remark/tagType/sort/status
+ */
+export function updateUserCategory(data) {
+  return request.put('/user/category', data)
+}
+
+/**
+ * 删除用户类别
+ * @param {number} id 类别ID
+ */
+export function deleteUserCategory(id) {
+  return request.delete(`/user/category/${id}`)
+}
+
+/**
+ * 修改用户类别状态
+ * @param {number} id     类别ID
+ * @param {number} status 目标状态:1=启用,0=停用
+ */
+export function updateUserCategoryStatus(id, status) {
+  return request.put(`/user/category/${id}/status`, null, { params: { status } })
+}

+ 15 - 3
code/frontend/src/components/ExcelExport.vue

@@ -12,7 +12,7 @@
 
 <script setup>
 import { ref } from 'vue'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import { Download } from '@element-plus/icons-vue'
 import { exportExcel } from '@/utils/excel'
 
@@ -48,7 +48,8 @@ const props = defineProps({
   fileName:   { type: String, default: 'export' },
   headers:    { type: Array, required: true },
   data:       { type: Array, default: null },
-  fetchData:  { type: Function, default: null }
+  fetchData:  { type: Function, default: null },
+  confirmMessage: { type: String, default: '' }
 })
 
 const loading = ref(false)
@@ -58,8 +59,16 @@ async function handleExport() {
     ElMessage.warning('请配置导出表头')
     return
   }
-  loading.value = true
   try {
+    if (props.confirmMessage) {
+      await ElMessageBox.confirm(props.confirmMessage, '提示', {
+        type: 'warning',
+        confirmButtonText: '是',
+        cancelButtonText: '否'
+      })
+    }
+
+    loading.value = true
     let exportData = props.data || []
     // 如果有 fetchData 函数,调用它获取全量数据
     if (props.fetchData) {
@@ -76,6 +85,9 @@ async function handleExport() {
     })
     ElMessage.success('导出成功')
   } catch (err) {
+    if (err === 'cancel' || err === 'close') {
+      return
+    }
     ElMessage.error(err?.message || '导出失败')
   } finally {
     loading.value = false

+ 136 - 0
code/frontend/src/components/RichEditor.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="rich-editor">
+    <Toolbar
+      :editor="editorRef"
+      :default-config="toolbarConfig"
+      :mode="mode"
+      class="rich-editor-toolbar"
+    />
+    <Editor
+      v-model="valueRef"
+      :default-config="editorConfig"
+      :mode="mode"
+      class="rich-editor-content"
+      :style="{ height: height }"
+      @on-created="handleCreated"
+      @on-change="handleChange"
+    />
+  </div>
+</template>
+
+<script setup>
+/**
+ * 富文本编辑器组件(基于 @wangeditor/editor)
+ * 特性:
+ * 1. 支持图片/视频上传到阿里云 OSS
+ * 2. 自动禁用 Base64 模式,强制使用 OSS 上传
+ * 3. 通过 v-model 绑定富文本 HTML 内容
+ */
+import { ref, shallowRef, onBeforeUnmount, watch } from 'vue'
+import '@wangeditor/editor/dist/css/style.css'
+import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+import { ElMessage } from 'element-plus'
+import { uploadImage, uploadVideo } from '@/api/content'
+
+const props = defineProps({
+  modelValue: { type: String, default: '' },
+  height: { type: String, default: '480px' },
+  mode: { type: String, default: 'default' }, // default | simple
+  placeholder: { type: String, default: '请输入文章内容...' }
+})
+const emit = defineEmits(['update:modelValue', 'change'])
+
+// 编辑器实例(shallowRef 避免响应式开销)
+const editorRef = shallowRef(null)
+// 双向绑定的 HTML 内容
+const valueRef = ref(props.modelValue || '')
+
+// 外部值变化时同步到编辑器
+watch(
+  () => props.modelValue,
+  (v) => {
+    if (v !== valueRef.value) {
+      valueRef.value = v || ''
+    }
+  }
+)
+
+// 工具栏配置(排除不需要的按钮)
+const toolbarConfig = {
+  excludeKeys: ['fullScreen']
+}
+
+// 编辑器配置
+const editorConfig = {
+  placeholder: props.placeholder,
+  MENU_CONF: {
+    // 自定义图片上传(使用后端 OSS 接口)
+    uploadImage: {
+      async customUpload(file, insertFn) {
+        try {
+          const res = await uploadImage(file)
+          const url = res?.data?.url
+          if (url) {
+            insertFn(url, file.name, url)
+          } else {
+            ElMessage.error('图片上传失败')
+          }
+        } catch (e) {
+          // 全局拦截器已经弹出错误,这里兜底
+        }
+      }
+    },
+    // 自定义视频上传
+    uploadVideo: {
+      async customUpload(file, insertFn) {
+        try {
+          const res = await uploadVideo(file)
+          const url = res?.data?.url
+          if (url) {
+            insertFn(url, url)
+          } else {
+            ElMessage.error('视频上传失败')
+          }
+        } catch (e) {
+          // 全局拦截器已经弹出错误
+        }
+      }
+    }
+  }
+}
+
+// 编辑器创建完成回调
+function handleCreated(editor) {
+  editorRef.value = editor
+}
+
+// 内容变化回调
+function handleChange(editor) {
+  const html = editor.getHtml()
+  emit('update:modelValue', html)
+  emit('change', html)
+}
+
+// 组件销毁时及时销毁编辑器实例,防止内存泄漏
+onBeforeUnmount(() => {
+  const editor = editorRef.value
+  if (editor == null) return
+  editor.destroy()
+})
+</script>
+
+<style lang="scss" scoped>
+.rich-editor {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  overflow: hidden;
+  z-index: 100;
+  background: #fff;
+}
+.rich-editor-toolbar {
+  border-bottom: 1px solid #dcdfe6;
+}
+.rich-editor-content {
+  overflow-y: hidden;
+}
+</style>

+ 12 - 0
code/frontend/src/router/index.js

@@ -88,6 +88,12 @@ const routes = [
         component: () => import('@/views/simulation/index.vue'),
         meta: { requiresAuth: true, title: '方案模拟测试', icon: 'DataAnalysis' }
       },
+      {
+        path: 'content',
+        name: 'Content',
+        component: () => import('@/views/content/index.vue'),
+        meta: { requiresAuth: true, title: '内容管理', icon: 'Document' }
+      },
       {
         path: 'system',
         name: 'System',
@@ -100,6 +106,12 @@ const routes = [
             component: () => import('@/views/admin/index.vue'),
             meta: { requiresAuth: true, title: '管理员', icon: 'Avatar' }
           },
+          {
+            path: 'user-category',
+            name: 'UserCategory',
+            component: () => import('@/views/system/user-category/index.vue'),
+            meta: { requiresAuth: true, title: '用户类别', icon: 'Collection' }
+          },
           {
             path: 'log',
             name: 'Log',

+ 240 - 9
code/frontend/src/utils/excel.js

@@ -26,15 +26,246 @@ export function exportExcel({ data = [], headers = [], fileName = 'export', shee
   const dataRows = data.map(row => headers.map(h => row[h.prop] ?? ''))
   // 合并表头和数据
   const sheetData = [headerRow, ...dataRows]
-  // 创建工作表
-  const worksheet = XLSX.utils.aoa_to_sheet(sheetData)
-  // 设置列宽(根据表头内容自动调整)
-  worksheet['!cols'] = headers.map(h => ({ wch: Math.max(h.label.length * 2, 12) }))
-  // 创建工作簿
-  const workbook = XLSX.utils.book_new()
-  XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
-  // 触发下载
-  XLSX.writeFile(workbook, `${fileName}.xlsx`)
+  const colWidths = headers.map(h => Math.max(h.label.length * 2, 12))
+  const workbookBlob = createStyledXlsx(sheetData, colWidths, sheetName)
+  downloadBlob(workbookBlob, `${fileName}.xlsx`)
+}
+
+function createStyledXlsx(sheetData, colWidths, sheetName) {
+  const files = [
+    ['[Content_Types].xml', contentTypesXml()],
+    ['_rels/.rels', rootRelsXml()],
+    ['docProps/app.xml', appXml()],
+    ['docProps/core.xml', coreXml()],
+    ['xl/workbook.xml', workbookXml(sheetName)],
+    ['xl/_rels/workbook.xml.rels', workbookRelsXml()],
+    ['xl/styles.xml', stylesXml()],
+    ['xl/worksheets/sheet1.xml', worksheetXml(sheetData, colWidths)]
+  ]
+  return new Blob([zipStore(files)], {
+    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+  })
+}
+
+function worksheetXml(sheetData, colWidths) {
+  const rowCount = Math.max(sheetData.length, 1)
+  const colCount = Math.max(colWidths.length, 1)
+  const dimension = `A1:${columnName(colCount)}${rowCount}`
+  const cols = colWidths.map((width, index) => (
+    `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`
+  )).join('')
+  const rows = sheetData.map((row, rowIndex) => {
+    const rowNumber = rowIndex + 1
+    const cells = row.map((value, colIndex) => cellXml(value, rowNumber, colIndex + 1, rowIndex === 0)).join('')
+    return `<row r="${rowNumber}">${cells}</row>`
+  }).join('')
+
+  return xmlDeclaration() +
+    `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ` +
+    `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
+    `<dimension ref="${dimension}"/><sheetViews><sheetView workbookViewId="0"/></sheetViews>` +
+    `<cols>${cols}</cols><sheetData>${rows}</sheetData></worksheet>`
+}
+
+function cellXml(value, rowNumber, colNumber, isHeader) {
+  const ref = `${columnName(colNumber)}${rowNumber}`
+  const style = isHeader ? ' s="1"' : ''
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return `<c r="${ref}"${style}><v>${value}</v></c>`
+  }
+  return `<c r="${ref}" t="inlineStr"${style}><is><t>${escapeXml(value)}</t></is></c>`
+}
+
+function stylesXml() {
+  return xmlDeclaration() +
+    '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
+    '<fonts count="2">' +
+    '<font><sz val="11"/><color rgb="FF000000"/><name val="Calibri"/><family val="2"/></font>' +
+    '<font><b/><sz val="14"/><color rgb="FF000000"/><name val="Calibri"/><family val="2"/></font>' +
+    '</fonts>' +
+    '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
+    '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
+    '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
+    '<cellXfs count="2">' +
+    '<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
+    '<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
+    '</cellXfs>' +
+    '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
+    '<dxfs count="0"/><tableStyles count="0" defaultTableStyle="TableStyleMedium9" defaultPivotStyle="PivotStyleMedium4"/>' +
+    '</styleSheet>'
+}
+
+function workbookXml(sheetName) {
+  return xmlDeclaration() +
+    '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
+    'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
+    `<sheets><sheet name="${escapeXml(sheetName).slice(0, 31) || 'Sheet1'}" sheetId="1" r:id="rId1"/></sheets>` +
+    '</workbook>'
+}
+
+function workbookRelsXml() {
+  return xmlDeclaration() +
+    '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
+    '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
+    '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
+    '</Relationships>'
+}
+
+function rootRelsXml() {
+  return xmlDeclaration() +
+    '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
+    '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
+    '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' +
+    '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' +
+    '</Relationships>'
+}
+
+function contentTypesXml() {
+  return xmlDeclaration() +
+    '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
+    '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
+    '<Default Extension="xml" ContentType="application/xml"/>' +
+    '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
+    '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
+    '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
+    '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
+    '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' +
+    '</Types>'
+}
+
+function appXml() {
+  return xmlDeclaration() +
+    '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' +
+    'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' +
+    '<Application>aijiuyi</Application></Properties>'
+}
+
+function coreXml() {
+  const now = new Date().toISOString()
+  return xmlDeclaration() +
+    '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' +
+    'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' +
+    'xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
+    '<dc:creator>aijiuyi</dc:creator><cp:lastModifiedBy>aijiuyi</cp:lastModifiedBy>' +
+    `<dcterms:created xsi:type="dcterms:W3CDTF">${now}</dcterms:created>` +
+    `<dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified>` +
+    '</cp:coreProperties>'
+}
+
+function zipStore(files) {
+  const encoder = new TextEncoder()
+  const chunks = []
+  const centralDirectory = []
+  let offset = 0
+
+  files.forEach(([name, content]) => {
+    const nameBytes = encoder.encode(name)
+    const dataBytes = encoder.encode(content)
+    const crc = crc32(dataBytes)
+    const localHeader = new Uint8Array(30 + nameBytes.length)
+    const localView = new DataView(localHeader.buffer)
+    localView.setUint32(0, 0x04034b50, true)
+    localView.setUint16(4, 20, true)
+    localView.setUint16(8, 0, true)
+    localView.setUint32(14, crc, true)
+    localView.setUint32(18, dataBytes.length, true)
+    localView.setUint32(22, dataBytes.length, true)
+    localView.setUint16(26, nameBytes.length, true)
+    localHeader.set(nameBytes, 30)
+    chunks.push(localHeader, dataBytes)
+
+    const centralHeader = new Uint8Array(46 + nameBytes.length)
+    const centralView = new DataView(centralHeader.buffer)
+    centralView.setUint32(0, 0x02014b50, true)
+    centralView.setUint16(4, 20, true)
+    centralView.setUint16(6, 20, true)
+    centralView.setUint32(16, crc, true)
+    centralView.setUint32(20, dataBytes.length, true)
+    centralView.setUint32(24, dataBytes.length, true)
+    centralView.setUint16(28, nameBytes.length, true)
+    centralView.setUint32(42, offset, true)
+    centralHeader.set(nameBytes, 46)
+    centralDirectory.push(centralHeader)
+    offset += localHeader.length + dataBytes.length
+  })
+
+  const centralOffset = offset
+  const centralSize = centralDirectory.reduce((sum, item) => sum + item.length, 0)
+  const endRecord = new Uint8Array(22)
+  const endView = new DataView(endRecord.buffer)
+  endView.setUint32(0, 0x06054b50, true)
+  endView.setUint16(8, files.length, true)
+  endView.setUint16(10, files.length, true)
+  endView.setUint32(12, centralSize, true)
+  endView.setUint32(16, centralOffset, true)
+
+  return concatUint8Arrays([...chunks, ...centralDirectory, endRecord])
+}
+
+function crc32(data) {
+  let crc = -1
+  for (let i = 0; i < data.length; i++) {
+    crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ data[i]) & 0xff]
+  }
+  return (crc ^ -1) >>> 0
+}
+
+const CRC_TABLE = (() => {
+  const table = new Uint32Array(256)
+  for (let i = 0; i < 256; i++) {
+    let value = i
+    for (let bit = 0; bit < 8; bit++) {
+      value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
+    }
+    table[i] = value >>> 0
+  }
+  return table
+})()
+
+function concatUint8Arrays(arrays) {
+  const totalLength = arrays.reduce((sum, item) => sum + item.length, 0)
+  const result = new Uint8Array(totalLength)
+  let offset = 0
+  arrays.forEach((item) => {
+    result.set(item, offset)
+    offset += item.length
+  })
+  return result
+}
+
+function downloadBlob(blob, fileName) {
+  const url = URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = fileName
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+  URL.revokeObjectURL(url)
+}
+
+function columnName(index) {
+  let name = ''
+  while (index > 0) {
+    const remainder = (index - 1) % 26
+    name = String.fromCharCode(65 + remainder) + name
+    index = Math.floor((index - 1) / 26)
+  }
+  return name
+}
+
+function escapeXml(value) {
+  return String(value ?? '')
+    .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;')
+}
+
+function xmlDeclaration() {
+  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
 }
 
 /**

+ 31 - 10
code/frontend/src/views/acupoint/index.vue

@@ -45,7 +45,8 @@
             <ExcelImport :on-import="handleImport" button-text="导入" @success="loadData" />
             <ExcelExport
               :headers="exportHeaders"
-              :data="tableData"
+              :fetch-data="fetchExportData"
+              confirm-message="是否导出当前筛选条件下的所有数据?"
               file-name="穴位列表"
             />
             <el-button type="primary" :icon="Plus" @click="handleAdd">新增</el-button>
@@ -309,6 +310,7 @@ import ExcelImport from '@/components/ExcelImport.vue'
 import ExcelExport from '@/components/ExcelExport.vue'
 import {
   getAcupointPage,
+  exportAcupoint,
   addAcupoint,
   updateAcupoint,
   deleteAcupoint,
@@ -414,21 +416,40 @@ async function loadData() {
     const res = await getAcupointPage(queryForm)
     const records = res.data?.records || []
     // 添加辅助显示字段
-    tableData.value = records.map(item => ({
-      ...item,
-      parseStatusLabel: parseStatusLabel(item.parseStatus),
-      parseConfidenceLabel: parseConfidenceLabel(item.parseConfidence),
-      sideLabel: ['', '中心', '左侧', '右侧', '双侧'][item.side] || '-',
-      genderLabel: ['不限', '仅男', '仅女'][item.gender] || '-',
-      statusLabel: item.status === 1 ? '活跃' : '待完善',
-      productAcupointLabel: item.productAcupoint === 1 ? '是' : '否'
-    }))
+    tableData.value = records.map(formatAcupointRow)
     total.value = res.data?.total || 0
   } finally {
     loading.value = false
   }
 }
 
+function formatAcupointRow(item) {
+  return {
+    ...item,
+    parseStatusLabel: parseStatusLabel(item.parseStatus),
+    parseConfidenceLabel: parseConfidenceLabel(item.parseConfidence),
+    sideLabel: ['', '中心', '左侧', '右侧', '双侧'][item.side] || '-',
+    genderLabel: ['不限', '仅男', '仅女'][item.gender] || '-',
+    statusLabel: item.status === 1 ? '活跃' : '待完善',
+    productAcupointLabel: item.productAcupoint === 1 ? '是' : '否'
+  }
+}
+
+/**
+ * 获取导出数据(全量,传入当前筛选条件)
+ */
+async function fetchExportData() {
+  const params = {
+    name: queryForm.name,
+    meridian: queryForm.meridian,
+    status: queryForm.status,
+    parseStatus: queryForm.parseStatus
+  }
+  const res = await exportAcupoint(params)
+  const list = res.data || []
+  return list.map(formatAcupointRow)
+}
+
 function parseStatusLabel(status) {
   const map = {
     '已解析': '已解析',

+ 1 - 0
code/frontend/src/views/admin/index.vue

@@ -222,6 +222,7 @@ const allMenuRoutes = [
   { path: '/plan', title: '方案管理' },
   { path: '/simulation', title: '方案模拟测试' },
   { path: '/admin', title: '管理员管理' },
+  { path: '/system/user-category', title: '用户类别' },
   { path: '/log', title: '日志管理' },
 ]
 

+ 607 - 0
code/frontend/src/views/content/index.vue

@@ -0,0 +1,607 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索区域 -->
+    <el-card class="search-card">
+      <el-form :model="queryForm" inline>
+        <el-form-item label="标题">
+          <el-input v-model="queryForm.title" placeholder="请输入文章标题" clearable style="width:200px" />
+        </el-form-item>
+        <el-form-item label="作者">
+          <el-input v-model="queryForm.author" placeholder="请输入作者" clearable style="width:160px" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="queryForm.status" placeholder="全部" clearable style="width:140px">
+            <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="!isSuperAdmin">
+          <el-checkbox v-model="onlyMine">只看我的</el-checkbox>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
+          <el-button :icon="Refresh" @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 表格区域 -->
+    <el-card class="table-card">
+      <template #header>
+        <div class="card-header">
+          <span class="title">文章列表</span>
+          <div class="btn-group">
+            <el-button type="primary" :icon="Plus" @click="handleAdd">新建文章</el-button>
+          </div>
+        </div>
+      </template>
+
+      <el-table :data="tableData" v-loading="loading" border stripe>
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="author" label="作者" width="120">
+          <template #default="{ row }">{{ row.author || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="coverImage" label="封面" width="80" align="center">
+          <template #default="{ row }">
+            <el-image
+              v-if="row.coverImage"
+              :src="row.coverImage"
+              :preview-src-list="[row.coverImage]"
+              fit="cover"
+              style="width: 48px; height: 48px; border-radius: 4px"
+              preview-teleported
+            />
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" label="状态" width="110" align="center">
+          <template #default="{ row }">
+            <el-tag :type="statusTagType(row.status)" size="small">
+              {{ statusLabel(row.status) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="viewCount" label="浏览量" width="90" align="center">
+          <template #default="{ row }">{{ row.viewCount || 0 }}</template>
+        </el-table-column>
+        <el-table-column prop="publishTime" label="发布时间" width="165">
+          <template #default="{ row }">{{ row.publishTime || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="updateTime" label="更新时间" width="165" />
+        <el-table-column label="操作" width="320" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button size="small" type="primary" link @click="handleView(row)">查看</el-button>
+            <el-button
+              size="small"
+              type="warning"
+              link
+              :disabled="!canEdit(row)"
+              @click="handleEdit(row)"
+            >编辑</el-button>
+            <el-button
+              v-if="canSubmit(row)"
+              size="small"
+              type="success"
+              link
+              @click="handleSubmit(row)"
+            >提交审核</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'PENDING'"
+              size="small"
+              type="success"
+              link
+              @click="handlePublish(row)"
+            >审核通过</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'PENDING'"
+              size="small"
+              type="danger"
+              link
+              @click="handleReject(row)"
+            >拒绝</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'PUBLISHED'"
+              size="small"
+              type="warning"
+              link
+              @click="handleOffline(row)"
+            >下架</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'OFFLINE'"
+              size="small"
+              type="success"
+              link
+              @click="handleOnline(row)"
+            >上架</el-button>
+            <el-button
+              v-if="isSuperAdmin"
+              size="small"
+              type="danger"
+              link
+              @click="handleDelete(row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="queryForm.pageNum"
+        v-model:page-size="queryForm.pageSize"
+        :page-sizes="[10, 20, 50]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        class="pagination"
+        @size-change="loadData"
+        @current-change="loadData"
+      />
+    </el-card>
+
+    <!-- 新增/编辑/查看弹窗 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="dialogTitle"
+      width="min(1100px, 92vw)"
+      top="6vh"
+      destroy-on-close
+      :close-on-click-modal="false"
+      @closed="resetForm"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="formRules"
+        label-width="100px"
+        :disabled="dialogMode === 'view'"
+      >
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="formData.title" maxlength="200" show-word-limit placeholder="请输入标题" />
+        </el-form-item>
+        <el-form-item label="作者">
+          <el-input v-model="formData.author" maxlength="50" placeholder="请输入作者名称" />
+        </el-form-item>
+        <el-form-item label="封面图">
+          <el-upload
+            :show-file-list="false"
+            :http-request="handleCoverUpload"
+            accept="image/*"
+            :disabled="dialogMode === 'view'"
+          >
+            <div class="cover-uploader">
+              <img v-if="formData.coverImage" :src="formData.coverImage" class="cover-preview" />
+              <el-icon v-else class="cover-icon"><Plus /></el-icon>
+            </div>
+          </el-upload>
+          <div class="form-tip">建议尺寸 16:9,支持 JPG/PNG/WEBP,单张不超过 5MB</div>
+        </el-form-item>
+        <el-form-item label="摘要">
+          <el-input
+            v-model="formData.summary"
+            type="textarea"
+            :rows="2"
+            maxlength="500"
+            show-word-limit
+            placeholder="请输入文章摘要(可选)"
+          />
+        </el-form-item>
+        <el-form-item label="内容" prop="content">
+          <RichEditor
+            v-if="dialogMode !== 'view'"
+            v-model="formData.content"
+            height="460px"
+          />
+          <!-- 查看模式:只读展示 HTML 内容 -->
+          <div v-else class="view-content" v-html="formData.content || '<i>(无内容)</i>'" />
+        </el-form-item>
+        <el-form-item v-if="dialogMode === 'view' && formData.rejectReason" label="拒绝原因">
+          <el-alert type="error" :closable="false" show-icon :title="formData.rejectReason" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">
+          {{ dialogMode === 'view' ? '关闭' : '取消' }}
+        </el-button>
+        <template v-if="dialogMode !== 'view'">
+          <el-button :loading="submitLoading" @click="handleSaveDraft">保存草稿</el-button>
+          <el-button type="primary" :loading="submitLoading" @click="handleSaveAndSubmit">
+            保存并提交审核
+          </el-button>
+        </template>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search, Refresh, Plus } from '@element-plus/icons-vue'
+import RichEditor from '@/components/RichEditor.vue'
+import { useUserStore } from '@/store/user'
+import {
+  getArticlePage,
+  getArticleById,
+  addArticle,
+  updateArticle,
+  deleteArticle,
+  submitArticle,
+  publishArticle,
+  rejectArticle,
+  offlineArticle,
+  onlineArticle,
+  uploadImage
+} from '@/api/content'
+
+const userStore = useUserStore()
+// 是否系统管理员(拥有全部权限)
+const isSuperAdmin = computed(() => userStore.adminRole === 1)
+
+// 查询表单
+const queryForm = reactive({
+  title: '',
+  author: '',
+  status: '',
+  createBy: null,
+  pageNum: 1,
+  pageSize: 10
+})
+const onlyMine = ref(false)
+const tableData = ref([])
+const total = ref(0)
+const loading = ref(false)
+
+// 状态选项
+const statusOptions = [
+  { value: 'DRAFT', label: '草稿' },
+  { value: 'PENDING', label: '待审核' },
+  { value: 'PUBLISHED', label: '已发布' },
+  { value: 'REJECTED', label: '已拒绝' },
+  { value: 'OFFLINE', label: '已下架' }
+]
+
+// 状态 label 映射
+function statusLabel(status) {
+  return statusOptions.find(i => i.value === status)?.label || status
+}
+// 状态 Tag 样式映射
+function statusTagType(status) {
+  switch (status) {
+    case 'DRAFT': return 'info'
+    case 'PENDING': return 'warning'
+    case 'PUBLISHED': return 'success'
+    case 'REJECTED': return 'danger'
+    case 'OFFLINE': return ''
+    default: return ''
+  }
+}
+
+// 弹窗状态
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const dialogMode = ref('add') // add | edit | view
+const submitLoading = ref(false)
+const formRef = ref(null)
+
+const defaultForm = () => ({
+  id: null,
+  title: '',
+  author: userStore.nickname || '',
+  coverImage: '',
+  summary: '',
+  content: '',
+  status: '',
+  rejectReason: ''
+})
+const formData = reactive(defaultForm())
+
+// 表单校验规则
+const formRules = {
+  title: [
+    { required: true, message: '请输入标题', trigger: 'blur' },
+    { max: 200, message: '标题最长200字符', trigger: 'blur' }
+  ],
+  content: [
+    { required: true, message: '请输入文章内容', trigger: 'change' }
+  ]
+}
+
+/**
+ * 加载列表
+ */
+async function loadData() {
+  loading.value = true
+  try {
+    queryForm.createBy = onlyMine.value ? (userStore.userInfo?.userId || null) : null
+    const res = await getArticlePage(queryForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  queryForm.pageNum = 1
+  loadData()
+}
+
+function handleReset() {
+  queryForm.title = ''
+  queryForm.author = ''
+  queryForm.status = ''
+  onlyMine.value = false
+  queryForm.pageNum = 1
+  loadData()
+}
+
+/**
+ * 判断当前用户是否有编辑权限
+ */
+function canEdit(row) {
+  if (isSuperAdmin.value) {
+    // 已发布的文章不可直接编辑
+    return row.status !== 'PUBLISHED'
+  }
+  // 普通管理员仅可编辑自己的草稿/已拒绝
+  return (
+    row.createBy === userStore.userInfo?.userId &&
+    (row.status === 'DRAFT' || row.status === 'REJECTED')
+  )
+}
+
+/**
+ * 判断是否可提交审核
+ */
+function canSubmit(row) {
+  const isOwner = row.createBy === userStore.userInfo?.userId
+  if (!isOwner && !isSuperAdmin.value) return false
+  return row.status === 'DRAFT' || row.status === 'REJECTED'
+}
+
+/**
+ * 新建文章
+ */
+function handleAdd() {
+  Object.assign(formData, defaultForm())
+  dialogMode.value = 'add'
+  dialogTitle.value = '新建文章'
+  dialogVisible.value = true
+}
+
+/**
+ * 编辑文章
+ */
+async function handleEdit(row) {
+  const res = await getArticleById(row.id)
+  Object.assign(formData, defaultForm(), res.data || {})
+  dialogMode.value = 'edit'
+  dialogTitle.value = '编辑文章'
+  dialogVisible.value = true
+}
+
+/**
+ * 查看文章
+ */
+async function handleView(row) {
+  const res = await getArticleById(row.id)
+  Object.assign(formData, defaultForm(), res.data || {})
+  dialogMode.value = 'view'
+  dialogTitle.value = '查看文章'
+  dialogVisible.value = true
+}
+
+/**
+ * 保存为草稿
+ */
+async function handleSaveDraft() {
+  const ok = await formRef.value.validate().catch(() => false)
+  if (!ok) return
+  submitLoading.value = true
+  try {
+    if (formData.id) {
+      await updateArticle(formData.id, formData)
+    } else {
+      await addArticle(formData)
+    }
+    ElMessage.success('已保存为草稿')
+    dialogVisible.value = false
+    loadData()
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+/**
+ * 保存并提交审核
+ */
+async function handleSaveAndSubmit() {
+  const ok = await formRef.value.validate().catch(() => false)
+  if (!ok) return
+  submitLoading.value = true
+  try {
+    let articleId = formData.id
+    if (articleId) {
+      await updateArticle(articleId, formData)
+    } else {
+      const res = await addArticle(formData)
+      // 如果后端返回了新增ID则使用,否则重新查询当前用户最新文章较麻烦;
+      // 改为前端不需要ID,直接在列表中再操作
+      articleId = res?.data?.id || null
+    }
+    if (articleId) {
+      await submitArticle(articleId)
+    }
+    ElMessage.success(articleId ? '已提交审核' : '已保存草稿,请在列表中提交审核')
+    dialogVisible.value = false
+    loadData()
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+/**
+ * 提交审核
+ */
+async function handleSubmit(row) {
+  await ElMessageBox.confirm(`确认提交文章「${row.title}」进行审核?`, '提示', { type: 'warning' })
+  await submitArticle(row.id)
+  ElMessage.success('已提交审核')
+  loadData()
+}
+
+/**
+ * 审核通过并发布
+ */
+async function handlePublish(row) {
+  await ElMessageBox.confirm(`确认审核通过并发布文章「${row.title}」?`, '提示', { type: 'success' })
+  await publishArticle(row.id)
+  ElMessage.success('文章已发布')
+  loadData()
+}
+
+/**
+ * 拒绝审核
+ */
+async function handleReject(row) {
+  const { value: reason } = await ElMessageBox.prompt('请输入拒绝原因', '审核拒绝', {
+    confirmButtonText: '确认拒绝',
+    cancelButtonText: '取消',
+    inputType: 'textarea',
+    inputPlaceholder: '请简要说明拒绝原因(必填)',
+    inputValidator: v => (v && v.trim() ? true : '拒绝原因不能为空')
+  })
+  await rejectArticle(row.id, reason.trim())
+  ElMessage.success('已拒绝审核')
+  loadData()
+}
+
+/**
+ * 下架
+ */
+async function handleOffline(row) {
+  await ElMessageBox.confirm(`确认下架文章「${row.title}」?`, '提示', { type: 'warning' })
+  await offlineArticle(row.id)
+  ElMessage.success('文章已下架')
+  loadData()
+}
+
+/**
+ * 重新上架
+ */
+async function handleOnline(row) {
+  await ElMessageBox.confirm(`确认重新上架文章「${row.title}」?`, '提示', { type: 'success' })
+  await onlineArticle(row.id)
+  ElMessage.success('文章已上架')
+  loadData()
+}
+
+/**
+ * 删除(逻辑删除)
+ */
+async function handleDelete(row) {
+  await ElMessageBox.confirm(`确认删除文章「${row.title}」?删除后不可恢复`, '危险操作', {
+    type: 'error',
+    confirmButtonText: '确认删除',
+    cancelButtonText: '取消'
+  })
+  await deleteArticle(row.id)
+  ElMessage.success('已删除')
+  loadData()
+}
+
+/**
+ * 封面图上传(自定义 http-request)
+ */
+async function handleCoverUpload({ file }) {
+  try {
+    const res = await uploadImage(file)
+    if (res?.data?.url) {
+      formData.coverImage = res.data.url
+      ElMessage.success('封面上传成功')
+    }
+  } catch (e) {
+    // 拦截器已提示
+  }
+}
+
+/**
+ * 关闭弹窗时重置表单
+ */
+function resetForm() {
+  Object.assign(formData, defaultForm())
+  formRef.value?.resetFields?.()
+}
+
+onMounted(() => {
+  loadData()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+.search-card {
+  :deep(.el-form-item) {
+    margin-bottom: 0;
+  }
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  .title {
+    font-size: 15px;
+    font-weight: 600;
+  }
+}
+.pagination {
+  margin-top: 16px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.cover-uploader {
+  width: 148px;
+  height: 88px;
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: border-color 0.2s;
+  overflow: hidden;
+  &:hover {
+    border-color: #409eff;
+  }
+  .cover-preview {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+  .cover-icon {
+    font-size: 28px;
+    color: #8c939d;
+  }
+}
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+  line-height: 1.4;
+}
+.view-content {
+  width: 100%;
+  padding: 8px 12px;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  min-height: 200px;
+  max-height: 500px;
+  overflow-y: auto;
+  background: #fafafa;
+  :deep(img) { max-width: 100%; }
+  :deep(video) { max-width: 100%; }
+}
+</style>

+ 201 - 11
code/frontend/src/views/device/index.vue

@@ -25,6 +25,38 @@
             <el-option label="v1.0.0" value="v1.0.0" />
           </el-select>
         </el-form-item>
+        <el-form-item label="省">
+          <el-select-v2
+            v-model="query.provinceCode"
+            :options="provinceList"
+            placeholder="全部"
+            clearable
+            filterable
+            style="width:160px"
+            @change="onQueryProvinceChange"
+          />
+        </el-form-item>
+        <el-form-item label="市">
+          <el-select-v2
+            v-model="query.cityCode"
+            :options="queryCityList"
+            placeholder="全部"
+            clearable
+            filterable
+            style="width:200px"
+            @change="onQueryCityChange"
+          />
+        </el-form-item>
+        <el-form-item label="区/县">
+          <el-select-v2
+            v-model="query.districtCode"
+            :options="queryDistrictList"
+            placeholder="全部"
+            clearable
+            filterable
+            style="width:240px"
+          />
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
           <el-button :icon="Refresh" @click="handleReset">重置</el-button>
@@ -66,7 +98,7 @@
         <el-table-column type="index" label="序号" width="60" align="center" />
         <el-table-column prop="deviceCode" label="设备编号" min-width="110" />
         <el-table-column prop="deviceName" label="设备名称" min-width="120" />
-        <el-table-column prop="deviceModel" label="设备型号" width="110">
+        <el-table-column prop="deviceModel" label="设备型号" width="90">
           <template #default="{ row }">{{ row.deviceModel || '-' }}</template>
         </el-table-column>
         <el-table-column label="绑定用户数" width="95" align="center">
@@ -75,7 +107,7 @@
           </template>
         </el-table-column>
         <el-table-column prop="boundUserNames" label="绑定用户" min-width="130">
-          <template #default="{ row }">{{ row.boundUserNames || '-' }}</template>
+          <template #default="{ row }">{{ formatBoundUserNames(row) }}</template>
         </el-table-column>
         <el-table-column label="在线状态" width="90" align="center">
           <template #default="{ row }">
@@ -87,10 +119,13 @@
         <el-table-column prop="firmwareVersion" label="固件版本" width="95">
           <template #default="{ row }">{{ row.firmwareVersion || '-' }}</template>
         </el-table-column>
-        <el-table-column prop="lastOnlineTime" label="最后在线时间" width="160">
+        <el-table-column label="所在地区" min-width="160">
+          <template #default="{ row }">{{ formatRegion(row) }}</template>
+        </el-table-column>
+        <el-table-column prop="lastOnlineTime" label="最后在线时间" width="140">
           <template #default="{ row }">{{ row.lastOnlineTime || '-' }}</template>
         </el-table-column>
-        <el-table-column label="操作" width="195" align="center" fixed="right">
+        <el-table-column label="操作" width="170" align="center" fixed="right">
           <template #default="{ row }">
             <el-button size="small" type="primary" link @click="handleView(row)">查看</el-button>
             <el-button size="small" type="warning" link @click="handleEdit(row)">编辑</el-button>
@@ -189,6 +224,26 @@
             />
           </el-select>
         </el-form-item>
+        <el-form-item label="所在地区">
+          <el-cascader
+            v-model="deviceForm.regionCodes"
+            :options="regionOptions"
+            :props="regionCascaderProps"
+            placeholder="请选择省/市/区"
+            clearable
+            filterable
+            style="width:100%"
+            :disabled="formMode === 'view'"
+          />
+        </el-form-item>
+        <el-form-item label="详细地址">
+          <el-input
+            v-model="deviceForm.address"
+            placeholder="请输入详细地址(选填)"
+            maxlength="200"
+            :disabled="formMode === 'view'"
+          />
+        </el-form-item>
         <el-form-item label="备注">
           <el-input
             v-model="deviceForm.remark"
@@ -215,10 +270,11 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, onMounted, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
+import { regionData, codeToText } from 'element-china-area-data'
 import ExcelImport from '@/components/ExcelImport.vue'
 import ExcelExport from '@/components/ExcelExport.vue'
 import {
@@ -241,6 +297,7 @@ const loading = ref(false)
 const selectedIds = ref([])
 const pageNum = ref(1)
 const pageSize = ref(10)
+const BOUND_USER_DISPLAY_LIMIT = 3
 
 /** 搜索条件 */
 const query = reactive({
@@ -248,14 +305,130 @@ const query = reactive({
   deviceName: '',
   boundUserName: '',
   onlineStatus: null,
-  firmwareVersion: ''
+  firmwareVersion: '',
+  provinceCode: '',
+  cityCode: '',
+  districtCode: ''
+})
+
+// ======================== 省市区数据 ========================
+const regionOptions = regionData
+const regionCascaderProps = { value: 'value', label: 'label', children: 'children' }
+
+// 查询区域:省列表
+const provinceList = computed(() => regionData.map(p => ({ value: p.value, label: p.label })))
+
+// 预计算"全国所有市"与"全国所有区"(模块级常量,仅计算一次)
+const ALL_CITIES = []
+const ALL_DISTRICTS = []
+regionData.forEach(p => {
+  ;(p.children || []).forEach(c => {
+    ALL_CITIES.push({ value: c.value, label: `${p.label} / ${c.label}` })
+    ;(c.children || []).forEach(d => {
+      ALL_DISTRICTS.push({ value: d.value, label: `${p.label} / ${c.label} / ${d.label}` })
+    })
+  })
 })
 
+/** 市列表:选了省则返回该省市;未选省则返回全国所有市 */
+const queryCityList = computed(() => {
+  if (query.provinceCode) {
+    const prov = regionData.find(p => p.value === query.provinceCode)
+    return (prov?.children || []).map(c => ({ value: c.value, label: c.label }))
+  }
+  return ALL_CITIES
+})
+
+/** 区/县列表:选了市则返回该市下区;只选省返回该省下区;都未选返回全国区 */
+const queryDistrictList = computed(() => {
+  if (query.cityCode) {
+    for (const p of regionData) {
+      const city = (p.children || []).find(c => c.value === query.cityCode)
+      if (city) {
+        return (city.children || []).map(d => ({ value: d.value, label: d.label }))
+      }
+    }
+    return []
+  }
+  if (query.provinceCode) {
+    const prov = regionData.find(p => p.value === query.provinceCode)
+    const all = []
+    ;(prov?.children || []).forEach(c => {
+      (c.children || []).forEach(d => {
+        all.push({ value: d.value, label: `${c.label} / ${d.label}` })
+      })
+    })
+    return all
+  }
+  return ALL_DISTRICTS
+})
+
+/** 选了省:如果已选的市/区不属于该省则清空 */
+function onQueryProvinceChange(val) {
+  if (!val) return
+  const prov = regionData.find(p => p.value === val)
+  const cities = prov?.children || []
+  if (query.cityCode && !cities.find(c => c.value === query.cityCode)) {
+    query.cityCode = ''
+    query.districtCode = ''
+  } else if (query.districtCode) {
+    const city = cities.find(c => c.value === query.cityCode)
+    if (city && !(city.children || []).find(d => d.value === query.districtCode)) {
+      query.districtCode = ''
+    }
+  }
+}
+
+/** 选了市:如果已选的区不属于该市则清空 */
+function onQueryCityChange(val) {
+  if (!val) return
+  for (const p of regionData) {
+    const city = (p.children || []).find(c => c.value === val)
+    if (city) {
+      if (query.districtCode && !(city.children || []).find(d => d.value === query.districtCode)) {
+        query.districtCode = ''
+      }
+      return
+    }
+  }
+}
+
+/** 根据行政区划编码还原为 "省/市/区" 文本 */
+function formatRegion(row) {
+  const parts = [row.provinceCode, row.cityCode, row.districtCode]
+    .filter(c => !!c)
+    .map(c => codeToText[c] || '')
+    .filter(t => !!t)
+  return parts.length > 0 ? parts.join(' / ') : '-'
+}
+
+/**
+ * 设备列表只展示最新绑定的前 3 个用户,更多用户用省略号提示。
+ * boundUserNames 由后端按绑定时间倒序拼接。
+ */
+function formatBoundUserNames(row) {
+  const names = String(row.boundUserNames || '')
+    .split('、')
+    .map(name => name.trim())
+    .filter(Boolean)
+
+  if (names.length === 0) {
+    return '-'
+  }
+
+  const total = Number(row.boundUserCount) || names.length
+  const displayText = names.slice(0, BOUND_USER_DISPLAY_LIMIT).join('、')
+  return total > BOUND_USER_DISPLAY_LIMIT || names.length > BOUND_USER_DISPLAY_LIMIT
+    ? `${displayText}...`
+    : displayText
+}
+
 /** 导出表头配置 */
 const exportHeaders = [
   { label: '设备编号', prop: 'deviceCode' },
   { label: '设备名称', prop: 'deviceName' },
   { label: '设备型号', prop: 'deviceModel' },
+  { label: '所在地区', prop: 'regionText' },
   { label: '绑定用户数', prop: 'boundUserCount' },
   { label: '绑定用户', prop: 'boundUserNames' },
   { label: '在线状态', prop: 'onlineStatusLabel' },
@@ -275,10 +448,11 @@ async function loadData() {
       pageSize: pageSize.value
     })
     const records = res.data?.records || []
-    // 为导出添加状态标签字段
+    // 为导出添加状态标签字段和地区文本
     tableData.value = records.map(r => ({
       ...r,
-      onlineStatusLabel: r.onlineStatus === 1 ? '在线' : '离线'
+      onlineStatusLabel: r.onlineStatus === 1 ? '在线' : '离线',
+      regionText: formatRegion(r)
     }))
     total.value = res.data?.total || 0
   } finally {
@@ -303,7 +477,10 @@ function handleReset() {
     deviceName: '',
     boundUserName: '',
     onlineStatus: null,
-    firmwareVersion: ''
+    firmwareVersion: '',
+    provinceCode: '',
+    cityCode: '',
+    districtCode: ''
   })
   pageNum.value = 1
   loadData()
@@ -330,6 +507,8 @@ const deviceForm = reactive({
   serialNo: '',
   firmwareVersion: '',
   userIds: [],
+  regionCodes: [],
+  address: '',
   remark: ''
 })
 
@@ -381,6 +560,8 @@ async function handleAdd() {
     serialNo: '',
     firmwareVersion: '',
     userIds: [],
+    regionCodes: [],
+    address: '',
     remark: ''
   })
   // 预加载用户选项
@@ -400,7 +581,9 @@ async function handleView(row) {
   userOptions.value = boundUsers.map(u => ({ id: u.userId, name: u.userName, phone: u.phone }))
   Object.assign(deviceForm, {
     ...row,
-    userIds: boundUsers.map(u => u.userId)
+    userIds: boundUsers.map(u => u.userId),
+    regionCodes: [row.provinceCode, row.cityCode, row.districtCode].filter(Boolean),
+    address: row.address || ''
   })
   formDialogVisible.value = true
 }
@@ -430,7 +613,9 @@ async function handleEdit(row) {
     serialNo: row.serialNo || '',
     firmwareVersion: row.firmwareVersion || '',
     remark: row.remark || '',
-    userIds: boundUsers.map(u => u.userId)
+    userIds: boundUsers.map(u => u.userId),
+    regionCodes: [row.provinceCode, row.cityCode, row.districtCode].filter(Boolean),
+    address: row.address || ''
   })
   formDialogVisible.value = true
 }
@@ -442,6 +627,7 @@ async function submitForm() {
   await deviceFormRef.value.validate()
   submitLoading.value = true
   try {
+    const codes = deviceForm.regionCodes || []
     const payload = {
       id: deviceForm.id,
       deviceCode: deviceForm.deviceCode,
@@ -449,6 +635,10 @@ async function submitForm() {
       deviceModel: deviceForm.deviceModel,
       serialNo: deviceForm.serialNo,
       firmwareVersion: deviceForm.firmwareVersion,
+      provinceCode: codes[0] || '',
+      cityCode: codes[1] || '',
+      districtCode: codes[2] || '',
+      address: deviceForm.address || undefined,
       remark: deviceForm.remark || undefined,
       userIds: deviceForm.userIds.length > 0 ? deviceForm.userIds : undefined
     }

+ 198 - 19
code/frontend/src/views/simulation/index.vue

@@ -26,7 +26,7 @@
                 <el-option
                   v-for="u in userOptions"
                   :key="u.id"
-                  :label="`${u.name || '未命名'} (${u.gender === 1 ? '男' : '女'}, ${u.shoulderWidth || '-'}cm)`"
+                  :label="`${u.name || '未命名'} (${u.gender === 1 ? '男' : '女'}, 肩宽${u.shoulderWidth || '-'}cm)`"
                   :value="u.id"
                 />
               </el-select>
@@ -136,6 +136,16 @@
                 开始模拟测试
               </el-button>
             </el-form-item>
+
+            <el-form-item v-if="showStaleVisualWarning" class="stale-alert-item">
+              <el-alert
+                class="visual-stale-alert"
+                type="warning"
+                show-icon
+                :closable="false"
+                :title="staleVisualWarningText"
+              />
+            </el-form-item>
           </el-form>
         </el-card>
 
@@ -196,9 +206,6 @@
           <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" />
@@ -208,6 +215,7 @@
 
               <rect width="100%" height="100%" fill="url(#simWash)" rx="14" />
               <image
+                v-if="showBodyPhoto"
                 :href="bodyBackImage"
                 x="0"
                 y="-10"
@@ -216,13 +224,106 @@
                 preserveAspectRatio="xMidYMid slice"
                 class="body-photo"
               />
-              <rect width="100%" height="100%" fill="url(#sim-grid)" rx="14" opacity="0.35" />
+
+              <g class="coord-plane">
+                <line
+                  v-for="mm in COORD_MINOR_X_LINES"
+                  :key="`minor-x-${mm}`"
+                  :x1="bodyToSvgX(mm)"
+                  y1="12"
+                  :x2="bodyToSvgX(mm)"
+                  y2="568"
+                  class="coord-grid-line minor"
+                />
+                <line
+                  v-for="mm in COORD_MINOR_Y_LINES"
+                  :key="`minor-y-${mm}`"
+                  x1="12"
+                  :y1="bodyToSvgY(mm)"
+                  x2="388"
+                  :y2="bodyToSvgY(mm)"
+                  class="coord-grid-line minor"
+                />
+                <line
+                  v-for="mm in COORD_MAJOR_X_LINES"
+                  :key="`major-x-${mm}`"
+                  :x1="bodyToSvgX(mm)"
+                  y1="12"
+                  :x2="bodyToSvgX(mm)"
+                  y2="568"
+                  class="coord-grid-line major"
+                />
+                <line
+                  v-for="mm in COORD_MAJOR_Y_LINES"
+                  :key="`major-y-${mm}`"
+                  x1="12"
+                  :y1="bodyToSvgY(mm)"
+                  x2="388"
+                  :y2="bodyToSvgY(mm)"
+                  class="coord-grid-line major"
+                />
+                <text
+                  v-for="mm in COORD_X_TICKS_MM"
+                  :key="`x-label-${mm}`"
+                  :x="bodyToSvgX(mm)"
+                  :y="SVG_C7_Y + 18"
+                  text-anchor="middle"
+                  class="coord-tick-label"
+                >
+                  {{ mm }}
+                </text>
+                <text
+                  v-for="mm in COORD_Y_TICKS_MM"
+                  :key="`y-label-${mm}`"
+                  :x="SVG_C7_X - 8"
+                  :y="bodyToSvgY(mm) - 4"
+                  text-anchor="end"
+                  class="coord-tick-label"
+                >
+                  {{ mm }}
+                </text>
+                <text x="382" :y="SVG_C7_Y + 34" text-anchor="end" class="coord-axis-label">X(mm)</text>
+                <text :x="SVG_C7_X + 12" y="42" class="coord-axis-label">Y(mm)</text>
+                <text :x="SVG_C7_X + 12" :y="SVG_C7_Y - 12" class="coord-origin-label">原点 C7</text>
+              </g>
 
               <line class="axis-line" :x1="SVG_C7_X" y1="12" :x2="SVG_C7_X" y2="568" stroke="rgba(148,163,184,0.4)" stroke-width="1" stroke-dasharray="4 6" />
               <line class="axis-line" x1="12" :y1="SVG_C7_Y" x2="388" :y2="SVG_C7_Y" stroke="rgba(148,163,184,0.4)" stroke-width="1" stroke-dasharray="5 5" />
 
               <!-- 背部中线参考 -->
               <line :x1="SVG_C7_X" :y1="SVG_C7_Y - 20" :x2="SVG_C7_X" :y2="SVG_S4_Y" stroke="rgba(99,102,241,0.35)" stroke-width="2" stroke-dasharray="8 6" stroke-linecap="round" />
+              <g v-if="currentShoulderWidthMm">
+                <line
+                  :x1="currentShoulderStartX"
+                  :y1="SVG_SHOULDER_Y"
+                  :x2="currentShoulderEndX"
+                  :y2="SVG_SHOULDER_Y"
+                  stroke="rgba(249,115,22,0.9)"
+                  stroke-width="3"
+                  stroke-linecap="round"
+                />
+                <line
+                  :x1="currentShoulderStartX"
+                  :y1="SVG_SHOULDER_Y - 8"
+                  :x2="currentShoulderStartX"
+                  :y2="SVG_SHOULDER_Y + 8"
+                  stroke="rgba(249,115,22,0.9)"
+                  stroke-width="2"
+                  stroke-linecap="round"
+                />
+                <line
+                  :x1="currentShoulderEndX"
+                  :y1="SVG_SHOULDER_Y - 8"
+                  :x2="currentShoulderEndX"
+                  :y2="SVG_SHOULDER_Y + 8"
+                  stroke="rgba(249,115,22,0.9)"
+                  stroke-width="2"
+                  stroke-linecap="round"
+                />
+                <text :x="SVG_C7_X" :y="SVG_SHOULDER_Y - 12" text-anchor="middle" font-size="10" fill="#c2410c" font-weight="700" font-family="system-ui, sans-serif">
+                  肩宽 {{ fmtNum(currentShoulderWidthCm) }} cm
+                </text>
+              </g>
               <line
                 :x1="SVG_C7_X + 18"
                 :y1="SVG_C7_Y"
@@ -254,8 +355,8 @@
 
               <text x="208" y="24" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">&uarr; 头侧</text>
               <text x="208" y="570" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">&darr; 尾侧</text>
-              <text x="14" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者左 (&minus;X)</text>
-              <text x="386" text-anchor="end" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者右 (+X)</text>
+              <text x="14" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">左 (&minus;X)</text>
+              <text x="386" text-anchor="end" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">右 (+X)</text>
 
               <!-- 穴位标记 -->
               <g v-for="(m, idx) in markers" :key="idx">
@@ -273,12 +374,6 @@
                 />
               </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>
 
@@ -306,7 +401,7 @@
           </div>
 
           <div v-if="unmappedSteps.length" class="visual-note">
-            <div class="visual-note-title">以下穴位未叠加到后背图</div>
+            <div class="visual-note-title">以下穴位未叠加到坐标系</div>
             <div class="visual-note-text">
               <span v-for="s in unmappedSteps" :key="s.stepOrder" class="visual-note-item">
                 {{ s.stepOrder }}. {{ s.acupointName }}
@@ -383,6 +478,20 @@ const filteredPlans = computed(() => {
   return list
 })
 
+const showStaleVisualWarning = computed(() =>
+  Boolean(pushData.value && selectedUserId.value && String(pushData.value.userId) !== String(selectedUserId.value))
+)
+
+const displayUserName = computed(() => {
+  if (!pushData.value) return ''
+  if (pushData.value.userName) return pushData.value.userName
+  const user = userOptions.value.find((u) => String(u.id) === String(pushData.value.userId))
+  return user?.name || '上一位'
+})
+
+const staleVisualWarningText = computed(() =>
+  `当前展示的是「${displayUserName.value}」用户数据,请点击「开始模拟测试」获取当前用户坐标展示`
+)
 
 const totalMinutes = computed(() => {
   const steps = pushData.value?.scheme?.steps || []
@@ -395,14 +504,24 @@ const pushDataJson = computed(() => {
   catch { return String(pushData.value) }
 })
 
-// SVG coordinate mapping: align mm offsets to the realistic back photo.
+// 保留人体图片接入点,后续需要恢复图片展示时只需打开此开关。
+const showBodyPhoto = false
+
+// SVG coordinate mapping: use C7 as the coordinate origin, X to patient right and Y to tail side.
 const SVG_C7_X = 200
 // 参考经络示意图:大椎位于颈后下缘、后正中线处,视觉上略低于头颈连接点
 const SVG_C7_Y = 112
+const SVG_SHOULDER_Y = SVG_C7_Y + 44
 const SVG_S4_Y = 520
 const DEFAULT_SPINE_LENGTH_MM = 450
 const MAX_VISUAL_SPINE_LENGTH_MM = 600
 const FRONT_ACUPOINT_NAMES = new Set(['中脘穴', '气海穴', '关元穴'])
+const COORD_MINOR_X_LINES = Array.from({ length: 23 }, (_, i) => -275 + i * 25).filter((mm) => mm !== 0)
+const COORD_MINOR_Y_LINES = Array.from({ length: 32 }, (_, i) => -125 + i * 25).filter((mm) => mm !== 0)
+const COORD_MAJOR_X_LINES = [-250, -200, -150, -100, -50, 50, 100, 150, 200, 250]
+const COORD_MAJOR_Y_LINES = [-100, 100, 200, 300, 400, 500, 600]
+const COORD_X_TICKS_MM = [-250, -200, -150, -100, -50, 50, 100, 150, 200, 250]
+const COORD_Y_TICKS_MM = [-100, 100, 200, 300, 400, 500, 600]
 
 const currentSpineLengthMm = computed(() => {
   const spineLengthCm = Number(pushData.value?.bodyData?.spineLength ?? bodyForm.spineLength)
@@ -416,14 +535,37 @@ const visualScale = (SVG_S4_Y - SVG_C7_Y) / MAX_VISUAL_SPINE_LENGTH_MM
 const currentSpineEndY = computed(() =>
   Math.min(SVG_S4_Y, SVG_C7_Y + currentSpineLengthMm.value * visualScale)
 )
+const currentShoulderWidthMm = computed(() => {
+  const shoulderWidthCm = Number(pushData.value?.bodyData?.shoulderWidth ?? bodyForm.shoulderWidth)
+  return Number.isFinite(shoulderWidthCm) && shoulderWidthCm > 0
+    ? shoulderWidthCm * 10
+    : null
+})
+const currentShoulderWidthCm = computed(() =>
+  currentShoulderWidthMm.value ? currentShoulderWidthMm.value / 10 : null
+)
+const currentShoulderStartX = computed(() =>
+  currentShoulderWidthMm.value ? bodyToSvgX(-currentShoulderWidthMm.value / 2) : SVG_C7_X
+)
+const currentShoulderEndX = computed(() =>
+  currentShoulderWidthMm.value ? bodyToSvgX(currentShoulderWidthMm.value / 2) : SVG_C7_X
+)
 
 function bodyToSvg(offsetXmm, offsetYmm) {
   return {
-    x: SVG_C7_X + Number(offsetXmm) * visualScale,
-    y: SVG_C7_Y + Number(offsetYmm) * visualScale,
+    x: bodyToSvgX(offsetXmm),
+    y: bodyToSvgY(offsetYmm),
   }
 }
 
+function bodyToSvgX(offsetXmm) {
+  return SVG_C7_X + Number(offsetXmm) * visualScale
+}
+
+function bodyToSvgY(offsetYmm) {
+  return SVG_C7_Y + Number(offsetYmm) * visualScale
+}
+
 function isBackVisualStep(step) {
   if (step.offsetX == null || step.offsetY == null) return false
   if (FRONT_ACUPOINT_NAMES.has(step.acupointName)) return false
@@ -541,10 +683,10 @@ async function loadUserPlans(userId) {
 }
 
 function onUserSelect(userId) {
-  pushData.value = null
   if (!userId) {
     Object.keys(bodyForm).forEach((k) => (bodyForm[k] = k === 'acupointTableId' ? null : ''))
     userPlans.value = []
+    pushData.value = null
     return
   }
   const u = userOptions.value.find((x) => x.id === userId)
@@ -594,7 +736,10 @@ async function runSimulation() {
   simulating.value = true
   try {
     const res = await getSimulationData(selectedUserId.value, selectedPlanId.value)
-    pushData.value = res.data
+    pushData.value = {
+      ...(res.data || {}),
+      userId: res.data?.userId ?? selectedUserId.value,
+    }
     ElMessage.success('模拟完成,查看右侧可视化结果')
   } catch (e) {
     ElMessage.error('模拟失败: ' + (e.message || '未知错误'))
@@ -710,6 +855,13 @@ watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
   font-size: 12px;
 }
 
+.stale-alert-item {
+  margin-top: -6px;
+}
+.visual-stale-alert {
+  width: 100%;
+}
+
 .body-canvas-wrap {
   display: flex;
   justify-content: center;
@@ -729,6 +881,33 @@ watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
 }
 .body-photo {
   pointer-events: none;
+  opacity: 0.52;
+}
+.coord-plane {
+  pointer-events: none;
+}
+.coord-grid-line {
+  vector-effect: non-scaling-stroke;
+}
+.coord-grid-line.minor {
+  stroke: rgba(148, 163, 184, 0.18);
+  stroke-width: 1;
+}
+.coord-grid-line.major {
+  stroke: rgba(100, 116, 139, 0.26);
+  stroke-width: 1;
+}
+.coord-tick-label,
+.coord-axis-label,
+.coord-origin-label {
+  fill: #64748b;
+  font-family: system-ui, sans-serif;
+  font-size: 9px;
+}
+.coord-axis-label,
+.coord-origin-label {
+  fill: #334155;
+  font-weight: 700;
 }
 
 .marker-legend {

+ 356 - 0
code/frontend/src/views/system/user-category/index.vue

@@ -0,0 +1,356 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索栏 -->
+    <el-card class="search-card" shadow="never">
+      <el-form :model="query" inline>
+        <el-form-item label="关键字">
+          <el-input
+            v-model="query.keyword"
+            placeholder="名称/备注"
+            clearable
+            style="width: 200px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="query.status" placeholder="全部" clearable style="width: 100px">
+            <el-option label="启用" :value="1" />
+            <el-option label="停用" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
+          <el-button :icon="Refresh" @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 表格卡片 -->
+    <el-card class="table-card" shadow="never">
+      <div class="toolbar">
+        <el-button type="primary" :icon="Plus" @click="handleAdd">新增类别</el-button>
+        <el-alert
+          title="内置类别(普通用户/专家/管理员)的编码不可修改、不可删除;类别一旦被用户使用也不可删除。"
+          type="info"
+          :closable="false"
+          show-icon
+          style="margin-left: 16px; flex: 1"
+        />
+      </div>
+
+      <el-table v-loading="loading" :data="tableData" border stripe>
+        <el-table-column label="序号" type="index" width="60" align="center" />
+        <el-table-column label="编码" prop="code" width="80" align="center" />
+        <el-table-column label="类别名称" min-width="140">
+          <template #default="{ row }">
+            <el-tag :type="row.tagType || 'primary'" size="small">{{ row.name }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="备注" prop="remark" min-width="220" show-overflow-tooltip />
+        <el-table-column label="标签颜色" prop="tagType" width="120" align="center" />
+        <el-table-column label="排序" prop="sort" width="80" align="center" />
+        <el-table-column label="是否内置" prop="builtin" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.builtin === 1 ? 'warning' : 'info'" size="small">
+              {{ row.builtin === 1 ? '内置' : '自定义' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" prop="status" width="90" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
+              {{ row.status === 1 ? '启用' : '停用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" prop="createTime" width="170" align="center" />
+        <el-table-column label="操作" width="220" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button
+              :type="row.status === 1 ? 'warning' : 'success'"
+              link
+              size="small"
+              @click="handleToggleStatus(row)"
+            >
+              {{ row.status === 1 ? '停用' : '启用' }}
+            </el-button>
+            <el-button
+              type="danger"
+              link
+              size="small"
+              :disabled="row.builtin === 1"
+              @click="handleDelete(row)"
+            >
+              删除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination">
+        <el-pagination
+          v-model:current-page="query.pageNum"
+          v-model:page-size="query.pageSize"
+          :page-sizes="[10, 20, 50]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          background
+          @size-change="loadData"
+          @current-change="loadData"
+        />
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑对话框 -->
+    <el-dialog
+      v-model="formVisible"
+      :title="formMode === 'add' ? '新增用户类别' : '编辑用户类别'"
+      width="520px"
+      :close-on-click-modal="false"
+      @close="handleFormClose"
+    >
+      <el-form ref="formRef" :model="formData" :rules="formRules" label-width="90px">
+        <el-form-item label="编码" prop="code">
+          <el-input-number
+            v-model="formData.code"
+            :min="1"
+            :max="9999"
+            :step="1"
+            :disabled="formMode === 'edit' && formData.builtin === 1"
+            placeholder="整数,唯一"
+            style="width: 100%"
+          />
+          <div class="form-tip">
+            内置编码:1=普通用户,2=专家,3=管理员;自定义编码建议从 4 开始
+          </div>
+        </el-form-item>
+        <el-form-item label="类别名称" prop="name">
+          <el-input v-model="formData.name" placeholder="最长50字符" maxlength="50" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input
+            v-model="formData.remark"
+            type="textarea"
+            :rows="2"
+            placeholder="备注说明"
+            maxlength="255"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="标签颜色" prop="tagType">
+          <el-select v-model="formData.tagType" style="width: 100%">
+            <el-option label="主要(蓝)" value="primary" />
+            <el-option label="成功(绿)" value="success" />
+            <el-option label="警告(橙)" value="warning" />
+            <el-option label="危险(红)" value="danger" />
+            <el-option label="信息(灰)" value="info" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="formData.sort" :min="0" :max="9999" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="formData.status">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">停用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="formVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search, Refresh, Plus } from '@element-plus/icons-vue'
+import { userCategoryApi } from '@/api'
+
+// ======================== 列表 ========================
+const loading = ref(false)
+const tableData = ref([])
+const total = ref(0)
+
+const query = reactive({
+  keyword: '',
+  status: null,
+  pageNum: 1,
+  pageSize: 10
+})
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await userCategoryApi.getUserCategoryPage({
+      keyword: query.keyword || undefined,
+      status: query.status ?? undefined,
+      pageNum: query.pageNum,
+      pageSize: query.pageSize
+    })
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  query.pageNum = 1
+  loadData()
+}
+
+function handleReset() {
+  query.keyword = ''
+  query.status = null
+  query.pageNum = 1
+  loadData()
+}
+
+// ======================== 新增/编辑 ========================
+const formVisible = ref(false)
+const formMode = ref('add')
+const submitLoading = ref(false)
+const formRef = ref()
+
+const formData = reactive({
+  id: null,
+  code: null,
+  name: '',
+  remark: '',
+  tagType: 'primary',
+  sort: 0,
+  status: 1,
+  builtin: 0
+})
+
+const formRules = {
+  code: [{ required: true, message: '请输入类别编码', trigger: 'blur' }],
+  name: [
+    { required: true, message: '请输入类别名称', trigger: 'blur' },
+    { max: 50, message: '名称最长50字符', trigger: 'blur' }
+  ],
+  status: [{ required: true, message: '请选择状态', trigger: 'change' }]
+}
+
+function resetForm() {
+  Object.assign(formData, {
+    id: null,
+    code: null,
+    name: '',
+    remark: '',
+    tagType: 'primary',
+    sort: 0,
+    status: 1,
+    builtin: 0
+  })
+}
+
+function handleAdd() {
+  formMode.value = 'add'
+  resetForm()
+  formVisible.value = true
+}
+
+function handleEdit(row) {
+  formMode.value = 'edit'
+  Object.assign(formData, {
+    id: row.id,
+    code: row.code,
+    name: row.name,
+    remark: row.remark || '',
+    tagType: row.tagType || 'primary',
+    sort: row.sort ?? 0,
+    status: row.status,
+    builtin: row.builtin
+  })
+  formVisible.value = true
+}
+
+function handleFormClose() {
+  formRef.value?.resetFields()
+}
+
+async function handleSubmit() {
+  await formRef.value?.validate()
+  submitLoading.value = true
+  try {
+    if (formMode.value === 'add') {
+      await userCategoryApi.addUserCategory({ ...formData })
+      ElMessage.success('新增类别成功')
+    } else {
+      await userCategoryApi.updateUserCategory({ ...formData })
+      ElMessage.success('修改成功')
+    }
+    formVisible.value = false
+    loadData()
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+// ======================== 状态切换 ========================
+async function handleToggleStatus(row) {
+  const newStatus = row.status === 1 ? 0 : 1
+  const label = newStatus === 1 ? '启用' : '停用'
+  await ElMessageBox.confirm(`确认${label}类别【${row.name}】吗?`, '提示', {
+    confirmButtonText: '确认',
+    cancelButtonText: '取消',
+    type: 'warning'
+  })
+  await userCategoryApi.updateUserCategoryStatus(row.id, newStatus)
+  ElMessage.success(`${label}成功`)
+  loadData()
+}
+
+// ======================== 删除 ========================
+async function handleDelete(row) {
+  await ElMessageBox.confirm(`确认删除类别【${row.name}】吗?此操作不可恢复`, '警告', {
+    confirmButtonText: '确认删除',
+    cancelButtonText: '取消',
+    type: 'warning'
+  })
+  await userCategoryApi.deleteUserCategory(row.id)
+  ElMessage.success('删除成功')
+  loadData()
+}
+
+onMounted(() => {
+  loadData()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.search-card :deep(.el-card__body) {
+  padding-bottom: 0;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.pagination {
+  margin-top: 16px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+  margin-top: 4px;
+}
+</style>

+ 45 - 9
code/frontend/src/views/user/app-user/index.vue

@@ -11,9 +11,12 @@
         </el-form-item>
         <el-form-item label="用户类型">
           <el-select v-model="queryForm.userType" placeholder="全部" clearable style="width:120px">
-            <el-option label="普通用户" :value="1" />
-            <el-option label="专家" :value="2" />
-            <el-option label="管理员" :value="3" />
+            <el-option
+              v-for="item in userCategoryOptions"
+              :key="item.code"
+              :label="item.name"
+              :value="item.code"
+            />
           </el-select>
         </el-form-item>
         <el-form-item label="状态">
@@ -45,10 +48,10 @@
         <el-table-column prop="userType" label="用户类型" width="100" align="center">
           <template #default="{ row }">
             <el-tag
-              :type="row.userType === 2 ? 'warning' : row.userType === 3 ? 'danger' : 'primary'"
+              :type="userTypeTagType(row.userType)"
               size="small"
             >
-              {{ row.userType === 2 ? '专家' : row.userType === 3 ? '管理员' : '普通用户' }}
+              {{ userTypeText(row.userType) }}
             </el-tag>
           </template>
         </el-table-column>
@@ -108,9 +111,12 @@
         </el-form-item>
         <el-form-item label="用户类型" prop="userType">
           <el-select v-model="formData.userType" placeholder="请选择" style="width:100%">
-            <el-option label="普通用户" :value="1" />
-            <el-option label="专家" :value="2" />
-            <el-option label="管理员" :value="3" />
+            <el-option
+              v-for="item in userCategoryOptions"
+              :key="item.code"
+              :label="item.name"
+              :value="item.code"
+            />
           </el-select>
         </el-form-item>
         <el-form-item label="状态" prop="status">
@@ -140,9 +146,27 @@ import {
   deleteAppUser,
   updateAppUserStatus
 } from '@/api/user'
+import { getUserCategoryList } from '@/api/userCategory'
 
 const router = useRouter()
 
+/** 用户类别选项(从后台 user_category 动态加载) */
+const userCategoryOptions = ref([])
+
+/** 用户类型文本 */
+function userTypeText(type) {
+  if (type == null) return '-'
+  const item = userCategoryOptions.value.find(c => c.code === type)
+  return item ? item.name : '-'
+}
+
+/** 用户类型标签颜色 */
+function userTypeTagType(type) {
+  if (type == null) return 'info'
+  const item = userCategoryOptions.value.find(c => c.code === type)
+  return item?.tagType || 'primary'
+}
+
 // 查询表单
 const queryForm = reactive({
   phone: '',
@@ -259,7 +283,19 @@ async function handleDelete(row) {
   loadData()
 }
 
-onMounted(loadData)
+async function loadUserCategories() {
+  try {
+    const res = await getUserCategoryList()
+    userCategoryOptions.value = res.data || []
+  } catch (e) {
+    userCategoryOptions.value = []
+  }
+}
+
+onMounted(() => {
+  loadUserCategories()
+  loadData()
+})
 </script>
 
 <style lang="scss" scoped>

+ 254 - 3
code/frontend/src/views/user/profile/index.vue

@@ -15,6 +15,48 @@
             <el-option label="女" :value="2" />
           </el-select>
         </el-form-item>
+        <el-form-item label="用户类型">
+          <el-select v-model="queryForm.userType" placeholder="全部" clearable style="width:120px">
+            <el-option
+              v-for="item in userCategoryOptions"
+              :key="item.code"
+              :label="item.name"
+              :value="item.code"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="省">
+          <el-select-v2
+            v-model="queryForm.provinceCode"
+            :options="provinceList"
+            placeholder="全部"
+            clearable
+            filterable
+            style="width:160px"
+            @change="onQueryProvinceChange"
+          />
+        </el-form-item>
+        <el-form-item label="市">
+          <el-select-v2
+            v-model="queryForm.cityCode"
+            :options="queryCityList"
+            placeholder="全部"
+            clearable
+            filterable
+            style="width:200px"
+            @change="onQueryCityChange"
+          />
+        </el-form-item>
+        <el-form-item label="区/县">
+          <el-select-v2
+            v-model="queryForm.districtCode"
+            :options="queryDistrictList"
+            placeholder="全部"
+            clearable
+            filterable
+            style="width:240px"
+          />
+        </el-form-item>
         <el-form-item label="注册时间">
           <el-date-picker
             v-model="queryForm.dateRange"
@@ -86,6 +128,26 @@
           <template #default="{ row }">{{ row.age != null ? row.age + '岁' : '-' }}</template>
         </el-table-column>
         <el-table-column prop="phone" label="手机号" width="130" />
+        <el-table-column prop="userType" label="用户类型" width="95" align="center">
+          <template #default="{ row }">
+            <el-tag :type="userTypeTagType(row.userType)" size="small" v-if="row.userType">
+              {{ userTypeText(row.userType) }}
+            </el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="所在地区" min-width="180">
+          <template #default="{ row }">
+            <span v-if="row.provinceCode">{{ formatRegion(row) }}</span>
+            <span v-else class="text-gray">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="address" label="详细地址" min-width="160" show-overflow-tooltip>
+          <template #default="{ row }">
+            <span v-if="row.address">{{ row.address }}</span>
+            <span v-else class="text-gray">-</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="deviceCodes" label="设备编号" min-width="140">
           <template #default="{ row }">
             <span v-if="row.deviceCodes">{{ row.deviceCodes }}</span>
@@ -168,6 +230,36 @@
               <el-input v-model="formData.phone" placeholder="请输入手机号" maxlength="11" :disabled="!!formData.id" />
             </el-form-item>
           </el-col>
+          <el-col :span="12">
+            <el-form-item label="用户类型" prop="userType">
+              <el-select v-model="formData.userType" placeholder="请选择用户类型" style="width:100%">
+                <el-option
+                  v-for="item in userCategoryOptions"
+                  :key="item.code"
+                  :label="item.name"
+                  :value="item.code"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="所在地区" prop="regionCodes">
+              <el-cascader
+                v-model="formData.regionCodes"
+                :options="regionOptions"
+                :props="regionCascaderProps"
+                placeholder="请选择省/市/区"
+                clearable
+                filterable
+                style="width:100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="详细地址" prop="address">
+              <el-input v-model="formData.address" placeholder="请输入详细地址" maxlength="200" />
+            </el-form-item>
+          </el-col>
           <el-col :span="12">
             <el-form-item label="肩宽(cm)" prop="shoulderWidth">
               <el-input-number v-model="formData.shoulderWidth" :min="0" :precision="1" style="width:100%" />
@@ -257,6 +349,7 @@ import { ref, reactive, onMounted, computed, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { useRouter } from 'vue-router'
 import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
+import { regionData, codeToText } from 'element-china-area-data'
 import ExcelExport from '@/components/ExcelExport.vue'
 import ExcelImport from '@/components/ExcelImport.vue'
 import {
@@ -268,6 +361,7 @@ import {
   batchImportProfile,
   generateUserAcupoint
 } from '@/api/user'
+import { getUserCategoryList } from '@/api/userCategory'
 
 const router = useRouter()
 
@@ -276,6 +370,10 @@ const queryForm = reactive({
   keyword: '',
   deviceCode: '',
   gender: null,
+  userType: null,
+  provinceCode: '',
+  cityCode: '',
+  districtCode: '',
   dateRange: null,
   acupointStatus: null,
   pageNum: 1,
@@ -294,6 +392,9 @@ const exportHeaders = [
   { label: '性别', prop: 'genderText' },
   { label: '年龄', prop: 'age' },
   { label: '手机号', prop: 'phone' },
+  { label: '用户类型', prop: 'userTypeLabel' },
+  { label: '所在地区', prop: 'regionText' },
+  { label: '详细地址', prop: 'address' },
   { label: '设备编号', prop: 'deviceCodes' },
   { label: '肩宽(cm)', prop: 'shoulderWidth' },
   { label: '身长(cm)', prop: 'bodyHeight' },
@@ -304,6 +405,119 @@ const exportHeaders = [
   { label: '注册时间', prop: 'createTime' }
 ]
 
+// 中国三级行政区划数据(省/市/区)
+const regionOptions = regionData
+const regionCascaderProps = { value: 'value', label: 'label', children: 'children' }
+
+// 查询区域:省列表、联动的市/区列表
+const provinceList = computed(() => regionData.map(p => ({ value: p.value, label: p.label })))
+
+// 预计算“全国所有市”与“全国所有区”(模块级常量,仅计算一次),
+// 避免清空上级时 computed 重复重建上千条数组导致卡顿
+const ALL_CITIES = []
+const ALL_DISTRICTS = []
+regionData.forEach(p => {
+  ;(p.children || []).forEach(c => {
+    ALL_CITIES.push({ value: c.value, label: `${p.label} / ${c.label}` })
+    ;(c.children || []).forEach(d => {
+      ALL_DISTRICTS.push({ value: d.value, label: `${p.label} / ${c.label} / ${d.label}` })
+    })
+  })
+})
+
+/** 市列表:选了省则返回该省市;未选省则返回全国所有市(加上省名以便识别) */
+const queryCityList = computed(() => {
+  if (queryForm.provinceCode) {
+    const prov = regionData.find(p => p.value === queryForm.provinceCode)
+    return (prov?.children || []).map(c => ({ value: c.value, label: c.label }))
+  }
+  return ALL_CITIES
+})
+
+/** 区/县列表:选了市则返回该市下区;只选省返回该省下区;都未选返回全国区(带上上级以便识别) */
+const queryDistrictList = computed(() => {
+  if (queryForm.cityCode) {
+    for (const p of regionData) {
+      const city = (p.children || []).find(c => c.value === queryForm.cityCode)
+      if (city) {
+        return (city.children || []).map(d => ({ value: d.value, label: d.label }))
+      }
+    }
+    return []
+  }
+  if (queryForm.provinceCode) {
+    const prov = regionData.find(p => p.value === queryForm.provinceCode)
+    const all = []
+    ;(prov?.children || []).forEach(c => {
+      (c.children || []).forEach(d => {
+        all.push({ value: d.value, label: `${c.label} / ${d.label}` })
+      })
+    })
+    return all
+  }
+  return ALL_DISTRICTS
+})
+
+/** 选了省:如果已选的市/区不属于该省则清空 */
+function onQueryProvinceChange(val) {
+  if (!val) {
+    return
+  }
+  const prov = regionData.find(p => p.value === val)
+  const cities = prov?.children || []
+  if (queryForm.cityCode && !cities.find(c => c.value === queryForm.cityCode)) {
+    queryForm.cityCode = ''
+    queryForm.districtCode = ''
+  } else if (queryForm.districtCode) {
+    const city = cities.find(c => c.value === queryForm.cityCode)
+    if (city && !(city.children || []).find(d => d.value === queryForm.districtCode)) {
+      queryForm.districtCode = ''
+    }
+  }
+}
+
+/** 选了市:如果已选的区不属于该市则清空 */
+function onQueryCityChange(val) {
+  if (!val) {
+    return
+  }
+  for (const p of regionData) {
+    const city = (p.children || []).find(c => c.value === val)
+    if (city) {
+      if (queryForm.districtCode && !(city.children || []).find(d => d.value === queryForm.districtCode)) {
+        queryForm.districtCode = ''
+      }
+      return
+    }
+  }
+}
+
+/** 用户类别选项(从后台 user_category 动态加载) */
+const userCategoryOptions = ref([])
+
+/** 用户类型文本(优先从动态加载的类别表查) */
+function userTypeText(type) {
+  if (type == null) return '-'
+  const item = userCategoryOptions.value.find(c => c.code === type)
+  return item ? item.name : '-'
+}
+
+/** 用户类型标签颜色(优先使用后台配置的 tagType) */
+function userTypeTagType(type) {
+  if (type == null) return 'info'
+  const item = userCategoryOptions.value.find(c => c.code === type)
+  return item?.tagType || 'primary'
+}
+
+/** 根据行政区划编码还原为 “省/市/区” 文本 */
+function formatRegion(row) {
+  const parts = [row.provinceCode, row.cityCode, row.districtCode]
+    .filter(c => !!c)
+    .map(c => codeToText[c] || '')
+    .filter(t => !!t)
+  return parts.join(' / ')
+}
+
 async function loadData() {
   loading.value = true
   try {
@@ -311,6 +525,10 @@ async function loadData() {
       keyword: queryForm.keyword || undefined,
       deviceCode: queryForm.deviceCode || undefined,
       gender: queryForm.gender,
+      userType: queryForm.userType,
+      provinceCode: queryForm.provinceCode || undefined,
+      cityCode: queryForm.cityCode || undefined,
+      districtCode: queryForm.districtCode || undefined,
       startTime: queryForm.dateRange?.[0] || undefined,
       endTime: queryForm.dateRange?.[1] || undefined,
       acupointStatus: queryForm.acupointStatus,
@@ -322,6 +540,8 @@ async function loadData() {
     const records = (res.data?.records || []).map(r => ({
       ...r,
       genderText: r.gender === 1 ? '男' : r.gender === 2 ? '女' : '-',
+      userTypeLabel: userTypeText(r.userType),
+      regionText: formatRegion(r),
       acupointText: r.acupointTableId ? '已生成' : '未生成'
     }))
     tableData.value = records
@@ -340,6 +560,10 @@ function handleReset() {
   queryForm.keyword = ''
   queryForm.deviceCode = ''
   queryForm.gender = null
+  queryForm.userType = null
+  queryForm.provinceCode = ''
+  queryForm.cityCode = ''
+  queryForm.districtCode = ''
   queryForm.dateRange = null
   queryForm.acupointStatus = null
   queryForm.pageNum = 1
@@ -362,6 +586,9 @@ const formData = reactive({
   gender: null,
   age: null,
   phone: '',
+  userType: 1,
+  regionCodes: [],
+  address: '',
   shoulderWidth: null,
   bodyHeight: null,
   spineLength: null,
@@ -446,6 +673,7 @@ const formRules = {
 function handleAdd() {
   Object.assign(formData, {
     id: null, name: '', gender: null, age: null, phone: '',
+    userType: 1, regionCodes: [], address: '',
     shoulderWidth: null, bodyHeight: null, spineLength: null, height: null, weight: null,
     fingerWidth1: null, fingerWidth15: null, fingerWidth3: null
   })
@@ -460,6 +688,9 @@ function handleEdit(row) {
     gender: row.gender,
     age: row.age,
     phone: row.phone,
+    userType: row.userType ?? 1,
+    regionCodes: [row.provinceCode, row.cityCode, row.districtCode].filter(c => !!c),
+    address: row.address || '',
     shoulderWidth: row.shoulderWidth,
     bodyHeight: row.bodyHeight,
     spineLength: row.spineLength,
@@ -484,10 +715,18 @@ async function handleSubmit() {
   await formRef.value.validate()
   submitLoading.value = true
   try {
+    const codes = formData.regionCodes || []
+    const payload = {
+      ...formData,
+      provinceCode: codes[0] || null,
+      cityCode: codes[1] || null,
+      districtCode: codes[2] || null
+    }
+    delete payload.regionCodes
     if (formData.id) {
-      await updateProfile({ ...formData })
+      await updateProfile(payload)
     } else {
-      await addProfile({ ...formData })
+      await addProfile(payload)
     }
     ElMessage.success('操作成功')
     dialogVisible.value = false
@@ -557,7 +796,19 @@ async function handleGenerateAcupoint() {
   }
 }
 
-onMounted(loadData)
+async function loadUserCategories() {
+  try {
+    const res = await getUserCategoryList()
+    userCategoryOptions.value = res.data || []
+  } catch (e) {
+    userCategoryOptions.value = []
+  }
+}
+
+onMounted(() => {
+  loadUserCategories()
+  loadData()
+})
 </script>
 
 <style lang="scss" scoped>

+ 111 - 0
code/内容管理.md

@@ -0,0 +1,111 @@
+## 十二、内容管理模块设计
+
+### 12.1 功能概述
+
+管理员在后台创建/编辑文章内容(富文本+图片+视频),提交审核后由系统管理员审批,审批通过后发布到APP展示。
+
+### 12.2 角色与权限
+
+| 角色 | 权限 | 说明 |
+|------|------|------|
+| 普通管理员 | 创建、编辑草稿,提交审核 | 可新建和修改文章,提交待审核 |
+| 系统管理员 | 审核通过/拒绝,发布/下架、物理删除 | 拥有最终审批权,可管理所有文章 |
+
+### 12.3 状态流转
+
+```
+草稿(DRAFT) → 待审核(PENDING) → 已发布(PUBLISHED)
+                ↓                     ↓
+             已拒绝(REJECTED)     已下架(OFFLINE)
+                ↓
+           物理删除(DELETED)
+```
+
+### 12.4 数据模型
+
+**文章表 (content_article)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| title | VARCHAR(200) | 文章标题 |
+| content | LONGTEXT | 富文本内容(含HTML标签、图片) |
+| cover_image | VARCHAR(500) | 封面图URL |
+| author | VARCHAR(50) | 作者 |
+| summary | VARCHAR(500) | 文章摘要 |
+| status | VARCHAR(20) | 状态 |
+| reject_reason | VARCHAR(500) | 拒绝原因 |
+| view_count | INT | 浏览次数 |
+| publish_time | DATETIME | 发布时间 |
+| create_by | BIGINT | 创建人ID |
+| create_time | DATETIME | 创建时间 |
+| update_by | BIGINT | 更新人ID |
+| update_time | DATETIME | 更新时间 |
+| deleted | TINYINT | 逻辑删除(0正常1删除) |
+
+**文章附件表 (content_attachment)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| article_id | BIGINT | 关联文章ID |
+| file_type | VARCHAR(20) | 文件类型(IMAGE/VIDEO) |
+| file_url | VARCHAR(500) | 文件URL |
+| file_size | INT | 文件大小(字节) |
+| file_name | VARCHAR(200) | 原文件名 |
+| create_time | DATETIME | 上传时间 |
+
+### 12.5 API设计
+
+**后台管理接口**
+
+| 方法 | 路径 | 说明 | 权限 |
+|------|------|------|------|
+| POST | /api/content/articles | 新建文章 | 普通管理员+ |
+| PUT | /api/content/articles/{id} | 编辑文章 | 普通管理员+ |
+| DELETE | /api/content/articles/{id} | 删除文章 | 系统管理员 |
+| GET | /api/content/articles/{id} | 获取文章详情 | 普通管理员+ |
+| GET | /api/content/articles | 文章列表(分页) | 普通管理员+ |
+| POST | /api/content/articles/{id}/submit | 提交审核 | 普通管理员+ |
+| POST | /api/content/articles/{id}/publish | 发布文章 | 系统管理员 |
+| POST | /api/content/articles/{id}/reject | 拒绝审核 | 系统管理员 |
+| POST | /api/content/articles/{id}/offline | 下架文章 | 系统管理员 |
+| POST | /api/content/articles/{id}/online | 上架文章 | 系统管理员 |
+
+**APP端接口**
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | /api/app/articles | 文章列表(已发布的) |
+| GET | /api/app/articles/{id} | 文章详情 |
+
+### 12.6 文件存储
+
+- **当前**:本地服务器目录存储
+- **上云时**:修改配置为OSS/七牛云URL,无需改代码
+
+**文件限制**
+
+| 类型 | 格式 | 大小限制 |
+|------|------|----------|
+| 图片 | JPG, PNG, GIF | 可配置(建议5MB) |
+| 视频 | MP4 | 可配置(建议50MB) |
+### 管理端
+管理端
+管理端功能都要有、app前端先不用实现
+
+
+
+阿里云oss访问控制
+AccessKey ID
+LTAI5t7NZAFYFAcmh68MvFLK
+AccessKey Secret
+hEs6C02BCKycfoq5AzHvFSb3KAMIyb
+存储桶:ajy001
+地区:华北2北京
+
+
+后端在backend
+管理端在frontend
+文字编写用富文本组件、支持上传到oss,以及展示。不要有bug,存储逻辑都要实现好。权限管理也要实现好。
+管理员都在app_user表里面。sys_user表没用

BIN
code/技术文档-Word版.docx


+ 729 - 0
code/技术文档.md

@@ -0,0 +1,729 @@
+# 艾灸椅管理后台系统 交接验收设计文档
+
+> 本文面向系统验收、运维交接和二次开发交接。内容以当前代码仓库为准,详细业务说明可结合附录中的专项文档查看。
+
+## 目录
+
+1. [文档说明](#1-文档说明)
+2. [项目概述](#2-项目概述)
+3. [交付范围](#3-交付范围)
+4. [系统架构](#4-系统架构)
+5. [功能模块验收清单](#5-功能模块验收清单)
+6. [核心业务流程](#6-核心业务流程)
+7. [数据设计与初始化](#7-数据设计与初始化)
+8. [核心算法说明](#8-核心算法说明)
+9. [接口与页面索引](#9-接口与页面索引)
+10. [安全与审计](#10-安全与审计)
+11. [部署运行说明](#11-部署运行说明)
+12. [验收建议](#12-验收建议)
+13. [交接资料](#13-交接资料)
+
+---
+
+## 1. 文档说明
+
+| 项目 | 内容 |
+|------|------|
+| 系统名称 | 艾灸椅管理后台系统 |
+| 后端工程 | `code/backend` |
+| 前端工程 | `code/frontend` |
+| 后端服务名 | `aijiuyi-admin` |
+| 文档用途 | 验收交接、部署说明、后续维护参考 |
+| 文档版本 | v1.1 |
+| 最后更新 | 2026-05-11 |
+
+---
+
+## 2. 项目概述
+
+### 2.1 系统定位
+
+艾灸椅管理后台系统是一套用于艾灸椅业务运营管理的 Web 后台,覆盖账号、用户档案、穴位、艾灸方案、设备、手法、用户穴位坐标、模拟测试和系统日志等管理能力。
+
+系统通过采集用户体型数据,结合穴位定位算法 3.0,为用户生成个性化穴位坐标,并支持将用户、设备、方案和穴位数据组织为可验收、可维护的后台业务闭环。
+
+### 2.2 使用角色
+
+| 角色 | 说明 |
+|------|------|
+| 超级管理员 | 拥有全部后台管理权限 |
+| 运营人员 | 负责日常数据维护、方案维护、设备与用户管理 |
+| 只读用户 | 查看业务数据和日志,原则上不做写入操作 |
+| 专家/普通用户 | 作为应用用户数据被后台管理,可参与方案归属和用户档案关联 |
+
+---
+
+## 3. 交付范围
+
+### 3.1 本次交付内容
+
+| 类型 | 交付物 | 说明 |
+|------|--------|------|
+| 后端源码 | `code/backend` | Spring Boot 后端服务,提供 REST API |
+| 前端源码 | `code/frontend` | Vue 3 后台管理页面 |
+| 数据库脚本 | `code/backend/src/main/resources/sql` | 初始化脚本、穴位导入脚本、算法迁移脚本 |
+| 配置文件 | `application.yml`、环境配置 | 服务端口、数据源、Redis、鉴权白名单等配置 |
+| 业务文档 | `code/*.md` | 各模块设计说明、算法说明、规范说明 |
+| 静态资源 | `code/frontend/src/assets` | 前端图片资源和页面展示素材 |
+
+### 3.2 范围说明
+
+本文重点说明当前仓库已实现的后台管理能力。设备固件、真实设备通信协议、外部系统联调细节,以对应交付物或联调文档为准。
+
+---
+
+## 4. 系统架构
+
+### 4.1 总体架构
+
+| 层级 | 组成 | 职责 |
+|------|------|------|
+| 前端层 | Vue 3、Vue Router、Pinia、Element Plus、Axios、Vite | 后台页面、表单交互、权限路由、接口调用 |
+| 后端层 | Spring Boot、Spring MVC、MyBatis Plus、Redis、AOP | 业务接口、鉴权拦截、数据处理、日志审计 |
+| 数据层 | MySQL、Redis | 业务数据存储、Token 状态管理 |
+| 部署层 | JDK、Maven、Node.js、Nginx | 后端运行、前端构建、静态资源和接口代理 |
+
+### 4.2 业务架构图
+
+![业务架构图](./业务架构图.png)
+
+### 4.3 技术栈
+
+| 分类 | 技术 |
+|------|------|
+| 后端 | JDK 1.8、Spring Boot 2.7.18、MyBatis Plus 3.5.3.1、JWT、FastJSON、Lombok |
+| 前端 | Vue 3.5、Vue Router 4.6、Pinia 3.0、Element Plus 2.13、Axios 1.14、Vite 8 |
+| 数据库 | MySQL 8.0+ |
+| 缓存 | Redis 6.0+ |
+| 构建工具 | Maven 3.6+、Node.js 20+、npm |
+
+### 4.4 项目结构摘要
+
+```text
+code/
+├── backend/                       后端工程
+│   ├── src/main/java/com/aijiuyi/admin
+│   │   ├── common/                通用配置、鉴权、异常、日志、工具
+│   │   ├── controller/            REST 接口控制器
+│   │   ├── service/               业务服务接口与实现
+│   │   ├── mapper/                MyBatis 数据访问层
+│   │   ├── entity/                数据实体
+│   │   └── controller/dto/        请求和响应 DTO
+│   ├── src/main/resources
+│   │   ├── mapper/                MyBatis XML
+│   │   ├── sql/                   初始化与迁移 SQL
+│   │   └── application*.yml       环境配置
+│   └── pom.xml
+│
+├── frontend/                      前端工程
+│   ├── src/api/                   接口封装
+│   ├── src/views/                 业务页面
+│   ├── src/router/                路由与登录守卫
+│   ├── src/store/                 用户状态
+│   ├── src/utils/                 请求、存储、Excel 工具
+│   └── package.json
+│
+└── *.md                           模块说明文档
+```
+
+---
+
+## 5. 功能模块验收清单
+
+| 模块 | 已实现能力 | 验收关注点 |
+|------|------------|------------|
+| 认证登录 | 登录、退出、当前用户信息、Token 校验 | 正确登录;退出后 Token 失效;未登录访问受限页面跳转登录 |
+| 账号管理 | 应用用户列表、详情、新增、编辑、删除、启停 | 管理员、专家、普通用户数据可维护;禁用账号不可正常使用 |
+| 管理员管理 | 管理员维护、角色维护、路由权限配置 | 超级管理员、运营人员、只读用户权限区分明确 |
+| 用户档案 | 子用户档案维护、批量导入、用户选项 | 肩宽、脊柱长度、身高、体重、指寸等数据校验正常 |
+| 穴位管理 | 穴位列表、详情、新增、编辑、删除、导入、导出 | 穴位名称、经络、定位描述、解析状态、坐标参数可维护 |
+| 用户穴位 | 根据用户档案生成个性化穴位坐标、查询坐标 | 输入体型数据后可生成 X/Y/Z 坐标和置信度 |
+| 方案管理 | 方案维护、步骤维护、发布、下架、批量操作 | 方案步骤可关联穴位和手法;发布/下架状态流转正确 |
+| 用户方案 | 用户方案分页、详情查询 | 能查看用户关联方案及方案明细 |
+| 设备管理 | 设备维护、批量导入、绑定用户、解绑用户、主用户设置 | 设备编号唯一;设备与用户绑定关系正确 |
+| 用户设备 | 从用户维度查看和维护设备绑定关系 | 绑定、解绑、主设备状态展示一致 |
+| 艾灸手法 | 手法维护、启停、温度/时间/运动参数管理 | 手法参数可被方案步骤引用 |
+| 模拟测试 | 按用户和方案组装模拟推送数据 | 能验证方案、穴位坐标、温度、时长、手法等输出数据 |
+| 系统日志 | 操作日志分页、导出、用户筛选 | 关键接口写操作可追踪操作人、时间、模块、请求结果 |
+
+---
+
+## 6. 核心业务流程
+
+### 6.1 登录与权限流程
+
+1. 用户提交用户名和密码。
+2. 后端校验账号状态和密码,生成 JWT Token。
+3. Token 写入 Redis,前端保存 Token 并进入后台。
+4. 前端路由守卫检查登录态;后端拦截器校验 Token 和白名单。
+5. 退出登录时,后端删除 Redis 中的 Token,使当前登录态失效。
+
+### 6.2 用户穴位生成流程
+
+1. 维护应用用户和子用户档案。
+2. 子用户档案录入肩宽、脊柱长度、身高、体重、指寸等体型数据。
+3. 后端根据穴位基础表和算法 3.0 计算用户个性化穴位坐标。
+4. 坐标结果写入 `user_acupoint`,包含 X/Y/Z 坐标、侧别、置信度。
+5. 方案模拟测试或后续设备联调可读取用户穴位坐标。
+
+### 6.3 方案维护流程
+
+1. 创建方案基础信息,设置模式、功效、适用人群和描述。
+2. 为方案添加步骤,步骤关联穴位、侧别、温度、时长和艾灸手法。
+3. 草稿方案确认无误后发布。
+4. 已发布方案可用于用户方案查询和模拟测试。
+5. 不再使用的方案可下架,保留历史数据兼容性。
+
+### 6.4 设备与用户绑定流程
+
+1. 录入或导入艾灸椅设备。
+2. 在设备详情中绑定一个或多个子用户。
+3. 可设置其中一个子用户为主用户。
+4. 支持单个解绑、批量解绑,并保持绑定关系状态可追踪。
+
+---
+
+## 7. 数据设计与初始化
+
+### 7.1 核心数据表清单
+
+| 表名 | 作用 | 关键数据 |
+|------|------|----------|
+| `app_user` | 应用用户与后台账号 | 用户名、手机号、密码、用户类型、管理员角色、状态 |
+| `admin_permission` | 管理员路由权限 | 管理员 ID、可访问路由 JSON |
+| `user_profile` | 子用户档案 | 姓名、性别、年龄、肩宽、脊柱长度、身高、体重、指寸 |
+| `acupoint` | 穴位基础数据 | 穴位编码、名称、经络、定位描述、偏移量、归一化 Y 系数 |
+| `user_acupoint` | 用户个性化穴位坐标 | 用户 ID、穴位 ID、侧别、X/Y/Z 坐标、置信度 |
+| `plan` | 艾灸方案主表 | 方案编码、名称、模式、功效、适用人群、状态 |
+| `plan_step` | 方案步骤 | 方案 ID、穴位、侧别、温度、时长、手法 |
+| `moxibustion_technique` | 艾灸手法 | 手法编码、运动模式、温度范围、时间范围、运动参数 |
+| `device` | 艾灸椅设备 | 设备编号、型号、序列号、固件版本、在线状态 |
+| `user_device` | 用户设备绑定关系 | 用户 ID、设备编码、绑定状态、主设备标记 |
+| `user_plan` | 用户方案 | 用户 ID、方案 ID、步骤 JSON、使用次数 |
+| `sys_log` | 系统操作日志 | 操作人、接口、参数、结果、模块、请求 ID |
+
+### 7.2 数据表结构明细
+
+#### 7.2.1 `sys_log` 系统操作日志表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `user_id` | BIGINT | - | idx_user_id | 请求用户 ID |
+| `user_name` | VARCHAR(100) | - | - | 请求用户名 |
+| `user_type` | TINYINT | - | - | 操作者类型:1=普通用户,2=专家,3=管理员 |
+| `request_url` | VARCHAR(500) | - | - | 请求接口路径 |
+| `request_params` | TEXT | - | - | 请求参数,JSON 格式 |
+| `request_result` | TEXT | - | - | 请求结果,JSON 格式,超长时截断 |
+| `api_desc` | VARCHAR(200) | - | - | 接口说明,来自 `@Log` 注解 |
+| `operation_type` | VARCHAR(20) | - | - | 操作方式:查询、新增、更新、删除、其他 |
+| `request_ip` | VARCHAR(50) | - | - | 请求客户端 IP |
+| `operation_module` | VARCHAR(100) | - | - | 操作模块,来自 `@Log` 注解 |
+| `request_id` | VARCHAR(50) | - | idx_request_id | 请求唯一 ID |
+| `create_time` | DATETIME | - | idx_create_time | 创建时间 |
+
+#### 7.2.2 `app_user` 应用用户表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `phone` | VARCHAR(20) | - | UNIQUE `uk_phone` | 手机号,用于登录 |
+| `username` | VARCHAR(64) | - | UNIQUE `uk_username` | 用户名,管理员登录用 |
+| `password` | VARCHAR(64) | - | - | 密码,MD5 加密,管理员用 |
+| `nickname` | VARCHAR(100) | - | - | 昵称 |
+| `avatar` | VARCHAR(500) | - | - | 头像 URL |
+| `user_type` | TINYINT | NOT NULL, DEFAULT 1 | - | 用户类型:1=普通用户,2=专家,3=管理员 |
+| `status` | TINYINT | NOT NULL, DEFAULT 1 | - | 状态:1=正常,0=禁用 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+| `admin_role` | TINYINT | DEFAULT NULL | - | 管理员角色:1=超级管理员,2=运营人员,3=只读用户 |
+| `last_login_time` | DATETIME | DEFAULT NULL | - | 最后登录时间 |
+
+#### 7.2.3 `admin_permission` 管理员权限配置表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `admin_id` | BIGINT | NOT NULL | UNIQUE `uk_admin_id` | 管理员 ID,关联 `app_user.id` |
+| `route_paths` | TEXT | - | - | 可访问路由路径,JSON 数组 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.4 `user_profile` 子用户信息表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `user_id` | BIGINT | - | idx_user_id | 所属应用用户 ID,关联 `app_user.id` |
+| `name` | VARCHAR(50) | NOT NULL | - | 姓名 |
+| `gender` | TINYINT | - | - | 性别:1=男,2=女 |
+| `age` | INT | - | - | 年龄 |
+| `phone` | VARCHAR(20) | - | idx_phone | 手机号 |
+| `shoulder_width` | DECIMAL(5,1) | - | - | 肩宽,单位 cm |
+| `body_height` | DECIMAL(5,1) | - | - | 身长/坐高,单位 cm |
+| `spine_length` | DECIMAL(5,1) | - | - | 脊柱长度 C7-S4,单位 cm,建议范围 30-60 |
+| `height` | DECIMAL(5,1) | - | - | 身高,单位 cm |
+| `weight` | DECIMAL(5,1) | - | - | 体重,单位 kg |
+| `finger_width_1` | DECIMAL(4,2) | - | - | 1 寸指宽,单位 cm |
+| `finger_width_15` | DECIMAL(4,2) | - | - | 1.5 寸指宽,单位 cm |
+| `finger_width_3` | DECIMAL(4,2) | - | - | 3 寸指宽,单位 cm |
+| `acupoint_table_id` | BIGINT | - | - | 用户穴位表 ID |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.5 `device` 艾灸椅设备表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `device_code` | VARCHAR(50) | NOT NULL | UNIQUE `uk_device_code` | 设备编号 |
+| `device_name` | VARCHAR(100) | NOT NULL | - | 设备名称 |
+| `device_model` | VARCHAR(50) | - | - | 设备型号 |
+| `serial_no` | VARCHAR(50) | - | UNIQUE `uk_serial_no` | 设备序列号 |
+| `firmware_version` | VARCHAR(20) | - | - | 固件版本 |
+| `online_status` | TINYINT | NOT NULL, DEFAULT 0 | - | 在线状态:1=在线,0=离线 |
+| `last_online_time` | DATETIME | - | - | 最后在线时间 |
+| `remark` | VARCHAR(200) | - | - | 备注 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.6 `user_device` 用户设备绑定表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `user_id` | BIGINT | NOT NULL | idx_user_id | 所属子用户 ID,关联 `user_profile.id` |
+| `device_code` | VARCHAR(100) | NOT NULL | idx_device_code | 设备唯一编码 |
+| `device_name` | VARCHAR(100) | - | - | 设备名称 |
+| `device_model` | VARCHAR(100) | - | - | 设备型号 |
+| `is_primary` | TINYINT | NOT NULL, DEFAULT 0 | - | 是否主设备:1=是,0=否 |
+| `online_status` | TINYINT | NOT NULL, DEFAULT 0 | - | 在线状态:1=在线,0=离线 |
+| `status` | TINYINT | NOT NULL, DEFAULT 1 | - | 绑定状态:1=已绑定,0=已解绑 |
+| `bind_time` | DATETIME | - | - | 绑定时间 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.7 `acupoint` 穴位信息表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `acupoint_code` | VARCHAR(100) | NOT NULL | UNIQUE `uk_acupoint_code` | 穴位编码,唯一标识 |
+| `excel_serial_no` | VARCHAR(20) | - | - | Excel 原始序号 |
+| `name` | VARCHAR(50) | NOT NULL | - | 穴位名称 |
+| `single_double` | VARCHAR(20) | - | - | 单/双穴,Excel 原始字段 |
+| `meridian` | VARCHAR(100) | - | - | 所属经络 |
+| `body_part` | VARCHAR(50) | - | - | 身体部位,Excel 原始字段 |
+| `body_region` | VARCHAR(50) | - | - | 部位分区,Excel 原始字段 |
+| `location_description` | VARCHAR(500) | - | - | 穴位定位原文 |
+| `relative_position` | VARCHAR(200) | - | - | 相对位置原文 |
+| `indications` | VARCHAR(1000) | - | - | 主管疾病/主治 |
+| `product_symptoms` | VARCHAR(500) | - | - | 产品症状覆盖 |
+| `acupoint_method` | VARCHAR(500) | - | - | 取穴说明 |
+| `product_acupoint` | TINYINT | NOT NULL, DEFAULT 0 | - | 是否艾灸椅产品穴位:1=是,0=否 |
+| `effects` | VARCHAR(500) | - | - | 功效描述,逗号分隔 |
+| `location_type` | TINYINT | - | - | 定位方式,当前固定为 1=相对定位 |
+| `longitude_offset` | DECIMAL(6,2) | - | - | 相对大椎穴纵向偏移,单位寸 |
+| `lateral_offset` | DECIMAL(6,2) | - | - | 相对大椎穴横向偏移,单位寸 |
+| `normalized_y` | DECIMAL(6,4) | - | - | Panjabi 归一化 Y 位置系数,范围 0-1 |
+| `y_reference_term` | VARCHAR(100) | - | - | Y 轴医学定位术语 |
+| `parse_status` | VARCHAR(20) | - | - | 定位描述解析状态:已解析、待复核、暂不支持 |
+| `parse_confidence` | VARCHAR(10) | - | - | 定位描述解析置信度:高、中、低 |
+| `parse_message` | VARCHAR(500) | - | - | 定位描述解析说明 |
+| `side` | TINYINT | - | - | 侧别:1=中心,2=左侧,3=右侧,4=双侧 |
+| `finger_width_type` | TINYINT | - | - | 指寸类型:1=一寸,2=1.5寸,3=三寸 |
+| `gender` | TINYINT | NOT NULL, DEFAULT 0 | - | 适用性别:0=不限,1=仅男,2=仅女 |
+| `status` | TINYINT | NOT NULL, DEFAULT 1 | - | 状态:1=活跃,0=待完善 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.8 `user_acupoint` 用户穴位坐标表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `user_id` | BIGINT | NOT NULL | idx_user_id | 子用户 ID,关联 `user_profile.id` |
+| `acupoint_id` | BIGINT | NOT NULL | idx_acupoint_id | 穴位 ID,关联 `acupoint.id` |
+| `acupoint_name` | VARCHAR(50) | NOT NULL | - | 穴位名称,冗余存储 |
+| `acupoint_side` | VARCHAR(10) | NOT NULL, DEFAULT `center` | - | 侧别:center=中心,left=左侧,right=右侧 |
+| `cun_value` | DECIMAL(6,2) | - | - | 相对大椎穴纵向偏移,单位寸 |
+| `cun_type` | VARCHAR(10) | - | - | 指寸类型:1寸、1.5寸、3寸 |
+| `coordinate_x` | DECIMAL(8,2) | - | - | X 坐标,单位 mm,向右为正 |
+| `coordinate_y` | DECIMAL(8,2) | - | - | Y 坐标,单位 mm,向下为正 |
+| `coordinate_z` | DECIMAL(8,2) | - | - | Z 坐标,单位 mm,体表为 0 |
+| `confidence` | VARCHAR(10) | - | - | 置信度:high=高,medium=中,low=低 |
+| `create_time` | DATETIME | - | - | 首次生成时间 |
+| `update_time` | DATETIME | - | - | 最后更新时间 |
+
+#### 7.2.9 `moxibustion_technique` 艾灸手法表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `technique_code` | VARCHAR(50) | NOT NULL | UNIQUE `uk_technique_code` | 手法编码,唯一标识 |
+| `name` | VARCHAR(50) | NOT NULL | - | 手法名称 |
+| `motion_mode` | VARCHAR(20) | NOT NULL | - | 运动模式:default、fixed、updown、circular、dotted |
+| `temp_min` | DECIMAL(4,1) | NOT NULL | - | 温度范围最小值,单位摄氏度 |
+| `temp_max` | DECIMAL(4,1) | NOT NULL | - | 温度范围最大值,单位摄氏度 |
+| `time_min` | INT | NOT NULL | - | 时间范围最小值,单位分钟 |
+| `time_max` | INT | NOT NULL | - | 时间范围最大值,单位分钟 |
+| `motion_amplitude` | DECIMAL(4,1) | - | - | 运动幅度,单位 mm |
+| `motion_speed` | DECIMAL(4,1) | - | - | 运动速度,单位 mm/s |
+| `status` | TINYINT | NOT NULL, DEFAULT 1 | - | 状态:1=启用,0=禁用 |
+| `remark` | VARCHAR(200) | - | - | 备注 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.10 `plan` 方案表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `plan_code` | VARCHAR(50) | NOT NULL | UNIQUE `uk_plan_code` | 方案编码 |
+| `name` | VARCHAR(100) | NOT NULL | - | 方案名称 |
+| `mode_type` | TINYINT | NOT NULL | - | 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 |
+| `effect_type` | VARCHAR(20) | - | - | 功效类型:驱寒、祛湿、祛风、化瘀、活血、化痰、养颜、扶阳 |
+| `symptoms` | VARCHAR(500) | - | - | 适用症状,逗号分隔 |
+| `applicable_gender` | TINYINT | NOT NULL, DEFAULT 0 | - | 适用人群:0=不限,1=仅男,2=仅女 |
+| `age_min` | INT | NOT NULL, DEFAULT 1 | - | 适用年龄最小值 |
+| `age_max` | INT | NOT NULL, DEFAULT 120 | - | 适用年龄最大值 |
+| `description` | VARCHAR(200) | - | - | 方案描述 |
+| `author_type` | TINYINT | NOT NULL, DEFAULT 1 | - | 创作人类型:1=管理员,2=普通用户,3=专家 |
+| `author_id` | BIGINT | - | - | 创作人 ID,关联 `app_user.id` |
+| `author_name` | VARCHAR(100) | - | - | 创作人名称,冗余存储 |
+| `status` | TINYINT | NOT NULL, DEFAULT 0 | - | 状态:0=草稿,1=已发布,2=已下架 |
+| `use_count` | INT | NOT NULL, DEFAULT 0 | - | 使用人数 |
+| `avg_rating` | DECIMAL(3,1) | - | - | 平均评分 |
+| `last_use_time` | DATETIME | - | - | 最后使用时间 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.11 `plan_step` 方案步骤表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `plan_id` | BIGINT | NOT NULL | idx_plan_id | 方案 ID,关联 `plan.id` |
+| `step_order` | INT | NOT NULL | - | 步骤序号,从 1 开始 |
+| `acupoint_id` | BIGINT | - | - | 穴位 ID,关联 `acupoint.id` |
+| `acupoint_name` | VARCHAR(50) | NOT NULL | - | 穴位名称,冗余存储 |
+| `side` | TINYINT | NOT NULL | - | 侧别:1=中心,2=左侧,3=右侧,4=双侧 |
+| `temperature` | DECIMAL(4,1) | NOT NULL | - | 温度,单位摄氏度,建议范围 35-50 |
+| `duration` | INT | NOT NULL | - | 时间,单位分钟,建议范围 1-30 |
+| `technique_id` | BIGINT | - | - | 艾灸手法 ID,关联 `moxibustion_technique.id` |
+| `technique_name` | VARCHAR(50) | - | - | 艾灸手法名称,冗余存储 |
+| `remark` | VARCHAR(100) | - | - | 备注 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 创建时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+#### 7.2.12 `user_plan` 用户方案表
+
+| 字段 | 类型 | 约束/默认值 | 索引 | 备注 |
+|------|------|-------------|------|------|
+| `id` | BIGINT | NOT NULL, AUTO_INCREMENT | PRIMARY KEY | 主键 ID |
+| `user_id` | BIGINT | NOT NULL | idx_user_id | 子用户 ID,关联 `user_profile.id` |
+| `plan_id` | BIGINT | NOT NULL | idx_plan_id | 方案 ID,关联 `plan.id` |
+| `plan_code` | VARCHAR(50) | NOT NULL | - | 方案编码,冗余存储 |
+| `plan_name` | VARCHAR(100) | NOT NULL | - | 方案名称,冗余存储 |
+| `mode_type` | TINYINT | NOT NULL | - | 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 |
+| `author_name` | VARCHAR(100) | - | - | 创作人名称,冗余存储 |
+| `step_count` | INT | NOT NULL, DEFAULT 0 | - | 步骤数量 |
+| `use_count` | INT | NOT NULL, DEFAULT 0 | - | 使用次数 |
+| `steps_json` | TEXT | - | - | 个性化工艺步骤,JSON 格式,含坐标、温度、时间等 |
+| `deleted` | TINYINT | NOT NULL, DEFAULT 0 | - | 逻辑删除:0=未删除,1=已删除 |
+| `create_time` | DATETIME | - | - | 订阅/生成时间 |
+| `update_time` | DATETIME | - | - | 更新时间 |
+
+### 7.3 数据兼容原则
+
+- 业务表使用 `deleted` 字段进行逻辑删除,避免物理删除影响历史数据。
+- 方案、穴位、手法、设备均使用编码或唯一编号辅助识别,便于导入和后续联调。
+- 穴位和方案数据涉及既有业务规则,维护时应保持字段含义和历史数据兼容。
+- 用户穴位坐标为计算结果数据,可按用户档案重新生成,但应避免无确认地覆盖生产验证数据。
+
+### 7.4 初始化与迁移脚本
+
+| 脚本 | 说明 |
+|------|------|
+| `init.sql` | 创建核心表并初始化管理员、穴位、手法、默认方案等基础数据 |
+| `acupoint_full_data_import.sql` | 完整穴位数据导入脚本 |
+| `migration_acupoint_auto_parse.sql` | 穴位定位解析相关字段迁移 |
+| `migration_algorithm_v3.sql` | 穴位定位算法 3.0 相关字段迁移 |
+
+默认初始化账号:
+
+| 用户名 | 密码 | 角色 |
+|--------|------|------|
+| `admin` | `admin123` | 超级管理员 |
+
+---
+
+## 8. 核心算法说明
+
+### 8.1 算法定位
+
+穴位定位算法 3.0 用于根据用户体型数据计算背部/颈部相关穴位的个性化坐标。该算法服务于艾灸椅定位和方案模拟,属于辅助定位能力,不替代医学诊断。
+
+### 8.2 输入参数
+
+| 参数 | 要求 | 用途 |
+|------|------|------|
+| 脊柱长度 C7-S4 | 必填,建议 30-60cm | 计算 Y 轴位置 |
+| 肩宽 | 必填,建议 30-50cm | 推算背部横向一寸宽度 |
+| 1 寸、1.5 寸、3 寸指宽 | 可选 | 提高指寸换算精度 |
+| 身高、体重 | 条件必填 | 未填指寸时用于 BMI 推算 |
+
+### 8.3 计算流程
+
+1. 根据实测指寸计算 A 路指寸值。
+2. 根据 BMI 计算 B 路指寸值。
+3. 融合 A/B 路结果,得到最终指寸和置信度。
+4. 结合肩宽和指寸计算背部一寸宽度。
+5. 根据 Panjabi 归一化 Y 系数计算纵向坐标。
+6. 根据经络侧线、区域系数和八髎穴规则计算横向坐标。
+7. 背部/颈部体表穴位 Z 坐标统一为 `0`。
+
+### 8.4 输出结果
+
+| 输出 | 说明 |
+|------|------|
+| `coordinate_x` | 横向坐标,单位 mm,右侧为正 |
+| `coordinate_y` | 纵向坐标,单位 mm,自 C7 向下 |
+| `coordinate_z` | 体表坐标,当前为 0 |
+| `confidence` | 坐标置信度:`high`、`medium`、`low` |
+
+### 8.5 代码位置
+
+| 内容 | 位置 |
+|------|------|
+| 用户穴位生成接口 | `UserAcupointController` |
+| 算法主实现 | `UserAcupointServiceImpl` |
+| 穴位定位解析 | `AcupointLocationParser` |
+| Y 轴参考项 | `AcupointYReference` |
+
+---
+
+## 9. 接口与页面索引
+
+### 9.1 接口基础信息
+
+后端服务配置:
+
+| 配置 | 值 |
+|------|----|
+| 服务端口 | `18888` |
+| 接口前缀 | `/api` |
+| 前端请求方式 | Axios 统一封装 |
+| Token 请求头 | `Authorization: Bearer {token}` |
+
+### 9.2 主要接口模块
+
+| 模块 | 后端路径 | 主要能力 |
+|------|----------|----------|
+| 认证 | `/api/auth` | 登录、退出、当前用户信息 |
+| 账号 | `/api/user/app` | 应用用户列表、详情、新增、编辑、删除、启停 |
+| 管理员 | `/api/admin` | 管理员维护、权限配置、状态管理 |
+| 用户档案 | `/api/user/profile` | 用户档案维护、导入、选项列表 |
+| 穴位 | `/api/acupoint` | 穴位维护、导入、导出、Y 轴参考项 |
+| 用户穴位 | `/api/user/acupoint` | 用户穴位坐标生成与查询 |
+| 方案 | `/api/plan` | 方案维护、发布、下架、批量操作 |
+| 用户方案 | `/api/user-plan` | 用户方案分页和详情 |
+| 设备 | `/api/device` | 设备维护、导入、设备绑定用户 |
+| 用户设备 | `/api/user/device` | 用户维度设备绑定管理 |
+| 艾灸手法 | `/api/moxibustion-technique` | 手法维护、启停、参数管理 |
+| 模拟测试 | `/api/simulation` | 方案模拟推送数据组装 |
+| 系统日志 | `/api/log` | 操作日志分页、导出、用户选项 |
+
+### 9.3 前端页面路由
+
+| 页面 | 路由 | 说明 |
+|------|------|------|
+| 登录 | `/login` | 后台登录页 |
+| 账号管理 | `/user/app` | 应用用户维护 |
+| 用户档案 | `/user/profile` | 子用户档案维护 |
+| 用户方案 | `/user/plan` | 用户方案查看 |
+| 用户设备 | `/user/device` | 用户设备绑定关系 |
+| 穴位管理 | `/acupoint` | 穴位数据维护 |
+| 设备管理 | `/device` | 设备数据维护 |
+| 设备用户 | `/device/users` | 设备绑定用户维护 |
+| 艾灸手法 | `/moxibustion` | 手法参数维护 |
+| 方案管理 | `/plan` | 艾灸方案维护 |
+| 模拟测试 | `/simulation` | 用户方案推送数据模拟 |
+| 管理员 | `/system/admin` | 管理员和权限维护 |
+| 系统日志 | `/system/log` | 操作日志查看 |
+
+---
+
+## 10. 安全与审计
+
+### 10.1 鉴权机制
+
+- 登录成功后生成 JWT Token,签名算法为 HS256。
+- Token 存入 Redis,用于控制登录态有效性和退出失效。
+- 前端请求统一携带 `Authorization: Bearer {token}`。
+- 后端 `TokenInterceptor` 校验 Token,并将用户信息写入请求上下文。
+- 白名单接口包括登录、注册、Actuator 和接口文档相关路径。
+
+### 10.2 权限控制
+
+| 权限项 | 说明 |
+|--------|------|
+| 用户类型 | 普通用户、专家、管理员 |
+| 管理员角色 | 超级管理员、运营人员、只读用户 |
+| 路由权限 | 通过 `admin_permission.route_paths` 控制可访问页面 |
+| 前端守卫 | 未登录时跳转登录页,已登录时加载用户权限 |
+
+### 10.3 操作审计
+
+- 使用 `@Log` 注解标记需要记录日志的业务接口。
+- `LogAspect` 统一记录操作人、接口、参数、结果、IP、模块和请求 ID。
+- 日志可在系统日志页面分页查看并导出。
+
+### 10.4 数据安全
+
+- 用户密码使用 MD5 存储。
+- 返回前端的用户信息不应返回明文密码。
+- 业务删除默认采用逻辑删除。
+- 生产环境应修改 JWT 密钥、数据库密码、Redis 密码等敏感配置。
+
+---
+
+## 11. 部署运行说明
+
+### 11.1 环境要求
+
+| 组件 | 版本 |
+|------|------|
+| JDK | 1.8+ |
+| Maven | 3.6+ |
+| MySQL | 8.0+ |
+| Redis | 6.0+ |
+| Node.js | 20+,当前验证环境为 v22.22.2 |
+| npm | 与 Node.js 版本匹配,当前验证环境为 10.9.7 |
+
+### 11.2 后端部署
+
+1. 创建数据库。
+
+```sql
+CREATE DATABASE aijiuyi_admin DEFAULT CHARACTER SET utf8mb4;
+```
+
+2. 执行初始化脚本。
+
+```bash
+mysql -u root -p aijiuyi_admin < code/backend/src/main/resources/sql/init.sql
+```
+
+3. 按环境修改数据库和 Redis 配置。
+
+```text
+code/backend/src/main/resources/application-dev.yml
+code/backend/src/main/resources/application-prod.yml
+```
+
+4. 打包并运行后端。
+
+```bash
+cd code/backend
+mvn clean package -DskipTests
+java -jar target/aijiuyi-admin-1.0.0.jar
+```
+
+### 11.3 前端部署
+
+```bash
+cd code/frontend
+npm install
+npm run build
+```
+
+构建产物位于 `code/frontend/dist`,可部署至 Nginx 等静态资源服务。
+
+### 11.4 Nginx 代理示例
+
+```nginx
+server {
+    listen 80;
+    server_name your-domain.com;
+
+    location / {
+        root /path/to/frontend/dist;
+        try_files $uri $uri/ /index.html;
+    }
+
+    location /api {
+        proxy_pass http://localhost:18888;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+}
+```
+
+### 11.5 常用端口
+
+| 服务 | 端口 |
+|------|------|
+| MySQL | `3306` |
+| Redis | `6379` |
+| 后端 API | `18888` |
+| 前端开发服务 | `5173` |
+
+---
+
+## 12. 验收建议
+
+### 12.1 基础验收
+
+- 使用初始化账号登录后台,确认菜单、路由和退出登录正常。
+- 新增、编辑、删除一条用户档案,确认表单校验和列表刷新正常。
+- 新增或编辑穴位、手法、设备,确认基础 CRUD 正常。
+- 创建方案并维护方案步骤,确认发布、下架、批量操作正常。
+- 绑定设备与用户,确认主用户设置、解绑、批量解绑正常。
+- 查看系统日志,确认关键操作产生审计记录。
+
+### 12.2 算法验收
+
+- 录入包含肩宽、脊柱长度、身高、体重和指寸的用户档案。
+- 触发用户穴位生成,确认用户穴位列表存在坐标数据。
+- 检查生成结果包含穴位名称、侧别、X/Y/Z 坐标和置信度。
+- 使用同一用户和方案执行模拟测试,确认输出数据包含方案步骤和穴位坐标。
+
+### 12.3 部署验收
+
+- 后端服务启动后可访问 `/api/auth/login`。
+- 前端构建成功,部署后页面可正常刷新和路由跳转。
+- MySQL、Redis 连接正常。
+- 生产环境配置与本地开发配置分离,敏感配置不提交到 Git。
+
+---
+
+## 13. 交接资料
+
+| 文档 | 说明 |
+|------|------|
+| `智能穴位定位系统设计文档.md` | 穴位定位整体设计 |
+| `算法3.0.md` | 算法 3.0 详细说明 |
+| `穴位管理.md` | 穴位管理模块说明 |
+| `用户管理.md` | 用户档案模块说明 |
+| `用户穴位管理.md` | 用户穴位坐标模块说明 |
+| `方案管理和用户方案管理.md` | 方案与用户方案说明 |
+| `设备管理和用户设备管理.md` | 设备及绑定关系说明 |
+| `艾灸手法管理.md` | 艾灸手法模块说明 |
+| `管理员管理.md` | 管理员与权限说明 |
+| `日志管理.md` | 操作日志说明 |
+| `规范说明.md` | 开发与协作规范 |
+
+---
+
+*文档版本:v1.1*
+*最后更新:2026-05-11*

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است