Browse Source

feat: 同步个人分支code目录到master

jiapu 4 tháng trước cách đây
mục cha
commit
30a8650e6f

+ 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));
+    }
+
     /**
      * 查询穴位详情
      *

+ 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轴计算的医学定位术语选项
      *

+ 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

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

@@ -23,7 +23,7 @@
             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

+ 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

+ 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

+ 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 = {
     '已解析': '已解析',

+ 26 - 4
code/frontend/src/views/device/index.vue

@@ -66,7 +66,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 +75,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 +87,10 @@
         <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 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>
@@ -241,6 +241,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({
@@ -251,6 +252,27 @@ const query = reactive({
   firmwareVersion: ''
 })
 
+/**
+ * 设备列表只展示最新绑定的前 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' },

+ 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 {

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*