index.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859
  1. <template>
  2. <div class="page-container">
  3. <!-- 搜索区域 -->
  4. <el-card class="search-card">
  5. <el-form :model="queryForm" inline>
  6. <el-form-item label="姓名/手机号">
  7. <el-input v-model="queryForm.keyword" placeholder="请输入姓名或手机号" clearable style="width:200px" />
  8. </el-form-item>
  9. <el-form-item label="设备编号">
  10. <el-input v-model="queryForm.deviceCode" placeholder="请输入设备编号" clearable style="width:180px" />
  11. </el-form-item>
  12. <el-form-item label="性别">
  13. <el-select v-model="queryForm.gender" placeholder="全部" clearable style="width:100px">
  14. <el-option label="男" :value="1" />
  15. <el-option label="女" :value="2" />
  16. </el-select>
  17. </el-form-item>
  18. <el-form-item label="用户类别">
  19. <el-select v-model="queryForm.userType" placeholder="全部" clearable style="width:120px">
  20. <el-option
  21. v-for="item in userCategoryOptions"
  22. :key="item.code"
  23. :label="item.name"
  24. :value="item.code"
  25. />
  26. </el-select>
  27. </el-form-item>
  28. <el-form-item label="省">
  29. <el-select-v2
  30. v-model="queryForm.provinceCode"
  31. :options="provinceList"
  32. placeholder="全部"
  33. clearable
  34. filterable
  35. style="width:160px"
  36. @change="onQueryProvinceChange"
  37. />
  38. </el-form-item>
  39. <el-form-item label="市">
  40. <el-select-v2
  41. v-model="queryForm.cityCode"
  42. :options="queryCityList"
  43. placeholder="全部"
  44. clearable
  45. filterable
  46. style="width:200px"
  47. @change="onQueryCityChange"
  48. />
  49. </el-form-item>
  50. <el-form-item label="区/县">
  51. <el-select-v2
  52. v-model="queryForm.districtCode"
  53. :options="queryDistrictList"
  54. placeholder="全部"
  55. clearable
  56. filterable
  57. style="width:240px"
  58. />
  59. </el-form-item>
  60. <el-form-item label="注册时间">
  61. <el-date-picker
  62. v-model="queryForm.dateRange"
  63. type="daterange"
  64. range-separator="至"
  65. start-placeholder="开始日期"
  66. end-placeholder="结束日期"
  67. format="YYYY-MM-DD HH:mm:ss"
  68. value-format="YYYY-MM-DD HH:mm:ss"
  69. :default-time="[new Date(0,0,0,0,0,0), new Date(0,0,0,23,59,59)]"
  70. style="width:360px"
  71. />
  72. </el-form-item>
  73. <el-form-item label="穴位表">
  74. <el-select v-model="queryForm.acupointStatus" placeholder="全部" clearable style="width:110px">
  75. <el-option label="已生成" :value="1" />
  76. <el-option label="未生成" :value="0" />
  77. </el-select>
  78. </el-form-item>
  79. <el-form-item>
  80. <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
  81. <el-button :icon="Refresh" @click="handleReset">重置</el-button>
  82. </el-form-item>
  83. </el-form>
  84. </el-card>
  85. <!-- 表格区域 -->
  86. <el-card class="table-card">
  87. <template #header>
  88. <div class="card-header">
  89. <span class="title">用户管理</span>
  90. <div class="btn-group">
  91. <ExcelImport :on-import="handleImport" button-text="导入" @success="onImportSuccess" />
  92. <ExcelExport
  93. :headers="exportHeaders"
  94. :data="tableData"
  95. file-name="用户列表"
  96. />
  97. <el-button type="primary" :icon="Plus" @click="handleAdd">新增</el-button>
  98. <el-button
  99. type="danger"
  100. :icon="Delete"
  101. :disabled="selectedIds.length === 0"
  102. @click="handleBatchDelete"
  103. >批量删除</el-button>
  104. </div>
  105. </div>
  106. </template>
  107. <el-table
  108. :data="tableData"
  109. v-loading="loading"
  110. border
  111. stripe
  112. @selection-change="handleSelectionChange"
  113. >
  114. <el-table-column type="selection" width="50" align="center" />
  115. <el-table-column type="index" label="序号" width="60" align="center" />
  116. <el-table-column prop="id" label="用户ID" width="80" align="center" />
  117. <el-table-column prop="name" label="姓名" width="100" />
  118. <el-table-column prop="gender" label="性别" width="70" align="center">
  119. <template #default="{ row }">
  120. <el-tag :type="row.gender === 1 ? 'primary' : 'danger'" size="small" v-if="row.gender">
  121. {{ row.gender === 1 ? '男' : '女' }}
  122. </el-tag>
  123. <span v-else>-</span>
  124. </template>
  125. </el-table-column>
  126. <el-table-column prop="age" label="年龄" width="65" align="center">
  127. <template #default="{ row }">{{ row.age != null ? row.age + '岁' : '-' }}</template>
  128. </el-table-column>
  129. <el-table-column prop="phone" label="手机号" width="130" />
  130. <el-table-column prop="userType" label="用户类别" width="95" align="center">
  131. <template #default="{ row }">
  132. <el-tag :type="userTypeTagType(row.userType)" size="small" v-if="row.userType">
  133. {{ userTypeText(row.userType) }}
  134. </el-tag>
  135. <span v-else>-</span>
  136. </template>
  137. </el-table-column>
  138. <el-table-column label="所在地区" min-width="180">
  139. <template #default="{ row }">
  140. <span v-if="row.provinceCode">{{ formatRegion(row) }}</span>
  141. <span v-else class="text-gray">-</span>
  142. </template>
  143. </el-table-column>
  144. <el-table-column prop="address" label="详细地址" min-width="160" show-overflow-tooltip>
  145. <template #default="{ row }">
  146. <span v-if="row.address">{{ row.address }}</span>
  147. <span v-else class="text-gray">-</span>
  148. </template>
  149. </el-table-column>
  150. <el-table-column prop="deviceCodes" label="设备编号" min-width="140">
  151. <template #default="{ row }">
  152. <span v-if="row.deviceCodes">{{ row.deviceCodes }}</span>
  153. <span v-else class="text-gray">-</span>
  154. </template>
  155. </el-table-column>
  156. <el-table-column prop="shoulderWidth" label="肩宽(cm)" width="90" align="center">
  157. <template #default="{ row }">{{ row.shoulderWidth ?? '-' }}</template>
  158. </el-table-column>
  159. <el-table-column prop="bodyHeight" label="身长(cm)" width="90" align="center">
  160. <template #default="{ row }">{{ row.bodyHeight ?? '-' }}</template>
  161. </el-table-column>
  162. <el-table-column prop="spineLength" label="脊柱长(cm)" width="100" align="center">
  163. <template #default="{ row }">{{ row.spineLength ?? '-' }}</template>
  164. </el-table-column>
  165. <el-table-column prop="height" label="身高(cm)" width="90" align="center">
  166. <template #default="{ row }">{{ row.height ?? '-' }}</template>
  167. </el-table-column>
  168. <el-table-column prop="weight" label="体重(kg)" width="90" align="center">
  169. <template #default="{ row }">{{ row.weight ?? '-' }}</template>
  170. </el-table-column>
  171. <el-table-column label="穴位表" width="90" align="center">
  172. <template #default="{ row }">
  173. <el-tag v-if="row.acupointTableId" type="success" size="small">已生成</el-tag>
  174. <el-tag v-else type="info" size="small">未生成</el-tag>
  175. </template>
  176. </el-table-column>
  177. <el-table-column prop="createTime" label="注册时间" width="170" :formatter="dateTimeFormatter" />
  178. <el-table-column label="操作" width="200" align="center" fixed="right">
  179. <template #default="{ row }">
  180. <el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
  181. <el-button size="small" type="success" link @click="handleDeviceManager(row)">设备管理</el-button>
  182. <el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
  183. </template>
  184. </el-table-column>
  185. </el-table>
  186. <!-- 分页 -->
  187. <el-pagination
  188. v-model:current-page="queryForm.pageNum"
  189. v-model:page-size="queryForm.pageSize"
  190. :page-sizes="[10, 20, 50]"
  191. :total="total"
  192. layout="total, sizes, prev, pager, next, jumper"
  193. class="pagination"
  194. @size-change="loadData"
  195. @current-change="loadData"
  196. />
  197. </el-card>
  198. <!-- 新增/编辑弹窗 -->
  199. <el-dialog
  200. v-model="dialogVisible"
  201. :title="dialogTitle"
  202. width="680px"
  203. destroy-on-close
  204. >
  205. <el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
  206. <el-row :gutter="16">
  207. <el-col :span="12">
  208. <el-form-item label="姓名" prop="name">
  209. <el-input v-model="formData.name" placeholder="请输入姓名" maxlength="50" />
  210. </el-form-item>
  211. </el-col>
  212. <el-col :span="12">
  213. <el-form-item label="性别" prop="gender">
  214. <el-radio-group v-model="formData.gender">
  215. <el-radio :label="1">男</el-radio>
  216. <el-radio :label="2">女</el-radio>
  217. </el-radio-group>
  218. </el-form-item>
  219. </el-col>
  220. <el-col :span="12">
  221. <el-form-item label="年龄" prop="age">
  222. <el-input-number v-model="formData.age" :min="1" :max="120" style="width:100%" />
  223. </el-form-item>
  224. </el-col>
  225. <el-col :span="12">
  226. <el-form-item label="手机号" prop="phone">
  227. <el-input v-model="formData.phone" placeholder="请输入手机号" maxlength="11" :disabled="!!formData.id" />
  228. </el-form-item>
  229. </el-col>
  230. <el-col v-if="formData.id" :span="12">
  231. <el-form-item label="用户类别" prop="userType">
  232. <el-select v-model="formData.userType" placeholder="请选择用户类别" style="width:100%">
  233. <el-option
  234. v-for="item in userCategoryOptions"
  235. :key="item.code"
  236. :label="item.name"
  237. :value="item.code"
  238. />
  239. </el-select>
  240. </el-form-item>
  241. </el-col>
  242. <el-col :span="12">
  243. <el-form-item label="所在地区" prop="regionCodes">
  244. <el-cascader
  245. v-model="formData.regionCodes"
  246. :options="regionOptions"
  247. :props="regionCascaderProps"
  248. placeholder="请选择省/市/区"
  249. clearable
  250. filterable
  251. style="width:100%"
  252. />
  253. </el-form-item>
  254. </el-col>
  255. <el-col :span="12">
  256. <el-form-item label="详细地址" prop="address">
  257. <el-input v-model="formData.address" placeholder="请输入详细地址" maxlength="200" />
  258. </el-form-item>
  259. </el-col>
  260. <el-col :span="12">
  261. <el-form-item label="肩宽(cm)" prop="shoulderWidth">
  262. <el-input-number v-model="formData.shoulderWidth" :min="0" :precision="1" style="width:100%" />
  263. </el-form-item>
  264. </el-col>
  265. <el-col :span="12">
  266. <el-form-item label="身长(cm)" prop="bodyHeight">
  267. <el-input-number v-model="formData.bodyHeight" :min="0" :precision="1" style="width:100%" />
  268. </el-form-item>
  269. </el-col>
  270. <el-col :span="12">
  271. <el-form-item label="脊柱长(cm)" prop="spineLength">
  272. <el-input-number v-model="formData.spineLength" :step="0.1" :precision="1" style="width:100%" />
  273. </el-form-item>
  274. </el-col>
  275. <el-col :span="12">
  276. <el-form-item label="身高(cm)" prop="height">
  277. <el-input-number v-model="formData.height" :min="0" :precision="1" style="width:100%" />
  278. </el-form-item>
  279. </el-col>
  280. <el-col :span="12">
  281. <el-form-item label="体重(kg)" prop="weight">
  282. <el-input-number v-model="formData.weight" :min="0" :precision="1" style="width:100%" />
  283. </el-form-item>
  284. </el-col>
  285. </el-row>
  286. <el-divider content-position="left"><span class="divider-text">指宽(选填,提高穴位精度)</span></el-divider>
  287. <el-row :gutter="16">
  288. <el-col :span="8">
  289. <el-form-item label="1寸(cm)" prop="fingerWidth1" label-width="70px">
  290. <el-input-number
  291. v-model="formData.fingerWidth1"
  292. :min="formData.gender ? fingerLimits.fw1.min : 0"
  293. :max="formData.gender ? fingerLimits.fw1.max : 99"
  294. :step="0.01"
  295. :precision="2"
  296. controls-position="right"
  297. style="width:100%"
  298. />
  299. </el-form-item>
  300. </el-col>
  301. <el-col :span="8">
  302. <el-form-item label="1.5寸(cm)" prop="fingerWidth15" label-width="75px">
  303. <el-input-number
  304. v-model="formData.fingerWidth15"
  305. :min="formData.gender ? fingerLimits.fw15.min : 0"
  306. :max="formData.gender ? fingerLimits.fw15.max : 99"
  307. :step="0.01"
  308. :precision="2"
  309. controls-position="right"
  310. style="width:100%"
  311. />
  312. </el-form-item>
  313. </el-col>
  314. <el-col :span="8">
  315. <el-form-item label="3寸(cm)" prop="fingerWidth3" label-width="70px">
  316. <el-input-number
  317. v-model="formData.fingerWidth3"
  318. :min="formData.gender ? fingerLimits.fw3.min : 0"
  319. :max="formData.gender ? fingerLimits.fw3.max : 99"
  320. :step="0.01"
  321. :precision="2"
  322. controls-position="right"
  323. style="width:100%"
  324. />
  325. </el-form-item>
  326. </el-col>
  327. </el-row>
  328. </el-form>
  329. <template #footer>
  330. <el-button @click="dialogVisible = false">取消</el-button>
  331. <el-button
  332. v-if="formData.id"
  333. type="warning"
  334. :loading="generateLoading"
  335. @click="handleGenerateAcupoint"
  336. >生成用户穴位</el-button>
  337. <el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
  338. </template>
  339. </el-dialog>
  340. </div>
  341. </template>
  342. <script setup>
  343. import { ref, reactive, onMounted, computed, watch } from 'vue'
  344. import { ElMessage, ElMessageBox } from 'element-plus'
  345. import { useRouter } from 'vue-router'
  346. import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
  347. import { regionData, codeToText } from 'element-china-area-data'
  348. import ExcelExport from '@/components/ExcelExport.vue'
  349. import ExcelImport from '@/components/ExcelImport.vue'
  350. import { dateTimeFormatter } from '@/utils/date'
  351. import {
  352. getProfilePage,
  353. addProfile,
  354. updateProfile,
  355. deleteProfile,
  356. batchDeleteProfile,
  357. batchImportProfile,
  358. generateUserAcupoint
  359. } from '@/api/user'
  360. import { getUserCategoryList } from '@/api/userCategory'
  361. const router = useRouter()
  362. // ======================== 列表查询 ========================
  363. const queryForm = reactive({
  364. keyword: '',
  365. deviceCode: '',
  366. gender: null,
  367. userType: null,
  368. provinceCode: '',
  369. cityCode: '',
  370. districtCode: '',
  371. dateRange: null,
  372. acupointStatus: null,
  373. pageNum: 1,
  374. pageSize: 10
  375. })
  376. const tableData = ref([])
  377. const total = ref(0)
  378. const loading = ref(false)
  379. const selectedIds = ref([])
  380. // Excel 导出列配置
  381. const exportHeaders = [
  382. { label: '用户ID', prop: 'id' },
  383. { label: '姓名', prop: 'name' },
  384. { label: '性别', prop: 'genderText' },
  385. { label: '年龄', prop: 'age' },
  386. { label: '手机号', prop: 'phone' },
  387. { label: '用户类别', prop: 'userTypeLabel' },
  388. { label: '所在地区', prop: 'regionText' },
  389. { label: '详细地址', prop: 'address' },
  390. { label: '设备编号', prop: 'deviceCodes' },
  391. { label: '肩宽(cm)', prop: 'shoulderWidth' },
  392. { label: '身长(cm)', prop: 'bodyHeight' },
  393. { label: '脊柱长度(cm)', prop: 'spineLength' },
  394. { label: '身高(cm)', prop: 'height' },
  395. { label: '体重(kg)', prop: 'weight' },
  396. { label: '穴位表', prop: 'acupointText' },
  397. { label: '注册时间', prop: 'createTime' }
  398. ]
  399. // 中国三级行政区划数据(省/市/区)
  400. const regionOptions = regionData
  401. const regionCascaderProps = { value: 'value', label: 'label', children: 'children' }
  402. // 查询区域:省列表、联动的市/区列表
  403. const provinceList = computed(() => regionData.map(p => ({ value: p.value, label: p.label })))
  404. // 预计算“全国所有市”与“全国所有区”(模块级常量,仅计算一次),
  405. // 避免清空上级时 computed 重复重建上千条数组导致卡顿
  406. const ALL_CITIES = []
  407. const ALL_DISTRICTS = []
  408. regionData.forEach(p => {
  409. ;(p.children || []).forEach(c => {
  410. ALL_CITIES.push({ value: c.value, label: `${p.label} / ${c.label}` })
  411. ;(c.children || []).forEach(d => {
  412. ALL_DISTRICTS.push({ value: d.value, label: `${p.label} / ${c.label} / ${d.label}` })
  413. })
  414. })
  415. })
  416. /** 市列表:选了省则返回该省市;未选省则返回全国所有市(加上省名以便识别) */
  417. const queryCityList = computed(() => {
  418. if (queryForm.provinceCode) {
  419. const prov = regionData.find(p => p.value === queryForm.provinceCode)
  420. return (prov?.children || []).map(c => ({ value: c.value, label: c.label }))
  421. }
  422. return ALL_CITIES
  423. })
  424. /** 区/县列表:选了市则返回该市下区;只选省返回该省下区;都未选返回全国区(带上上级以便识别) */
  425. const queryDistrictList = computed(() => {
  426. if (queryForm.cityCode) {
  427. for (const p of regionData) {
  428. const city = (p.children || []).find(c => c.value === queryForm.cityCode)
  429. if (city) {
  430. return (city.children || []).map(d => ({ value: d.value, label: d.label }))
  431. }
  432. }
  433. return []
  434. }
  435. if (queryForm.provinceCode) {
  436. const prov = regionData.find(p => p.value === queryForm.provinceCode)
  437. const all = []
  438. ;(prov?.children || []).forEach(c => {
  439. (c.children || []).forEach(d => {
  440. all.push({ value: d.value, label: `${c.label} / ${d.label}` })
  441. })
  442. })
  443. return all
  444. }
  445. return ALL_DISTRICTS
  446. })
  447. /** 选了省:如果已选的市/区不属于该省则清空 */
  448. function onQueryProvinceChange(val) {
  449. if (!val) {
  450. return
  451. }
  452. const prov = regionData.find(p => p.value === val)
  453. const cities = prov?.children || []
  454. if (queryForm.cityCode && !cities.find(c => c.value === queryForm.cityCode)) {
  455. queryForm.cityCode = ''
  456. queryForm.districtCode = ''
  457. } else if (queryForm.districtCode) {
  458. const city = cities.find(c => c.value === queryForm.cityCode)
  459. if (city && !(city.children || []).find(d => d.value === queryForm.districtCode)) {
  460. queryForm.districtCode = ''
  461. }
  462. }
  463. }
  464. /** 选了市:如果已选的区不属于该市则清空 */
  465. function onQueryCityChange(val) {
  466. if (!val) {
  467. return
  468. }
  469. for (const p of regionData) {
  470. const city = (p.children || []).find(c => c.value === val)
  471. if (city) {
  472. if (queryForm.districtCode && !(city.children || []).find(d => d.value === queryForm.districtCode)) {
  473. queryForm.districtCode = ''
  474. }
  475. return
  476. }
  477. }
  478. }
  479. /** 用户类别选项(从后台 user_category 动态加载) */
  480. const userCategoryOptions = ref([])
  481. /** 用户类别文本(优先从动态加载的类别表查) */
  482. function userTypeText(type) {
  483. if (type == null) return '-'
  484. const item = userCategoryOptions.value.find(c => c.code === type)
  485. return item ? item.name : '-'
  486. }
  487. /** 用户类别标签颜色(优先使用后台配置的 tagType) */
  488. function userTypeTagType(type) {
  489. if (type == null) return 'info'
  490. const item = userCategoryOptions.value.find(c => c.code === type)
  491. return item?.tagType || 'primary'
  492. }
  493. /** 根据行政区划编码还原为 “省/市/区” 文本 */
  494. function formatRegion(row) {
  495. const parts = [row.provinceCode, row.cityCode, row.districtCode]
  496. .filter(c => !!c)
  497. .map(c => codeToText[c] || '')
  498. .filter(t => !!t)
  499. return parts.join(' / ')
  500. }
  501. async function loadData() {
  502. loading.value = true
  503. try {
  504. const params = {
  505. keyword: queryForm.keyword || undefined,
  506. deviceCode: queryForm.deviceCode || undefined,
  507. gender: queryForm.gender,
  508. userType: queryForm.userType,
  509. provinceCode: queryForm.provinceCode || undefined,
  510. cityCode: queryForm.cityCode || undefined,
  511. districtCode: queryForm.districtCode || undefined,
  512. startTime: queryForm.dateRange?.[0] || undefined,
  513. endTime: queryForm.dateRange?.[1] || undefined,
  514. acupointStatus: queryForm.acupointStatus,
  515. pageNum: queryForm.pageNum,
  516. pageSize: queryForm.pageSize
  517. }
  518. const res = await getProfilePage(params)
  519. // 附加显示文本字段(用于 Excel 导出)
  520. const records = (res.data?.records || []).map(r => ({
  521. ...r,
  522. genderText: r.gender === 1 ? '男' : r.gender === 2 ? '女' : '-',
  523. userTypeLabel: userTypeText(r.userType),
  524. regionText: formatRegion(r),
  525. acupointText: r.acupointTableId ? '已生成' : '未生成'
  526. }))
  527. tableData.value = records
  528. total.value = res.data?.total || 0
  529. } finally {
  530. loading.value = false
  531. }
  532. }
  533. function handleSearch() {
  534. queryForm.pageNum = 1
  535. loadData()
  536. }
  537. function handleReset() {
  538. queryForm.keyword = ''
  539. queryForm.deviceCode = ''
  540. queryForm.gender = null
  541. queryForm.userType = null
  542. queryForm.provinceCode = ''
  543. queryForm.cityCode = ''
  544. queryForm.districtCode = ''
  545. queryForm.dateRange = null
  546. queryForm.acupointStatus = null
  547. queryForm.pageNum = 1
  548. loadData()
  549. }
  550. function handleSelectionChange(selection) {
  551. selectedIds.value = selection.map(r => r.id)
  552. }
  553. // ======================== 新增/编辑 ========================
  554. const dialogVisible = ref(false)
  555. const dialogTitle = ref('')
  556. const submitLoading = ref(false)
  557. const generateLoading = ref(false)
  558. const formRef = ref(null)
  559. const formData = reactive({
  560. id: null,
  561. name: '',
  562. gender: null,
  563. age: null,
  564. phone: '',
  565. userType: 1,
  566. regionCodes: [],
  567. address: '',
  568. shoulderWidth: null,
  569. bodyHeight: null,
  570. spineLength: null,
  571. height: null,
  572. weight: null,
  573. fingerWidth1: null,
  574. fingerWidth15: null,
  575. fingerWidth3: null
  576. })
  577. const SPINE_LENGTH_MIN = 30
  578. const SPINE_LENGTH_MAX = 60
  579. // 各指宽字段在不同性别下的有效范围
  580. const fingerLimits = computed(() => {
  581. const male = formData.gender === 1
  582. return {
  583. fw1: { label: '1寸', min: male ? 1.5 : 1.3, max: male ? 2.8 : 2.4 },
  584. fw15: { label: '1.5寸', min: male ? 2.2 : 2.0, max: male ? 4.0 : 3.5 },
  585. fw3: { label: '3寸', min: male ? 4.5 : 4.0, max: male ? 8.0 : 7.0 }
  586. }
  587. })
  588. /**
  589. * 生成指宽字段的自定义 validator(选填,填了才按性别范围校验)
  590. * @param {string} field fw1 | fw15 | fw3
  591. */
  592. function makeFingerValidator(field) {
  593. return (rule, value, callback) => {
  594. if (value === null || value === undefined) {
  595. return callback()
  596. }
  597. if (!formData.gender) {
  598. return callback(new Error('请先选择性别再填写指宽'))
  599. }
  600. const { label, min, max } = fingerLimits.value[field]
  601. const genderText = formData.gender === 1 ? '男性' : '女性'
  602. if (value < min || value > max) {
  603. return callback(new Error(`${genderText}${label}指宽应在 ${min}~${max} cm 之间`))
  604. }
  605. callback()
  606. }
  607. }
  608. function validateSpineLength(rule, value, callback) {
  609. if (value === null || value === undefined || value === '') {
  610. return callback(new Error('请输入C7-S4脊柱长度'))
  611. }
  612. const numericValue = Number(value)
  613. if (!Number.isFinite(numericValue)) {
  614. return callback(new Error('脊柱长度格式不正确'))
  615. }
  616. if (numericValue < SPINE_LENGTH_MIN || numericValue > SPINE_LENGTH_MAX) {
  617. return callback(new Error(`C7-S4脊柱长度需在${SPINE_LENGTH_MIN}-${SPINE_LENGTH_MAX}cm之间`))
  618. }
  619. callback()
  620. }
  621. const formRules = {
  622. name: [
  623. { required: true, message: '请输入姓名', trigger: 'blur' },
  624. { max: 50, message: '姓名不超过50个字符', trigger: 'blur' }
  625. ],
  626. gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
  627. age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
  628. phone: [
  629. { required: true, message: '请输入手机号', trigger: 'blur' },
  630. { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  631. ],
  632. shoulderWidth: [{ required: true, message: '请输入肩宽', trigger: 'blur' }],
  633. bodyHeight: [{ required: true, message: '请输入身长', trigger: 'blur' }],
  634. spineLength: [{ required: true, message: '请输入C7-S4脊柱长度', trigger: 'blur' }],
  635. fingerWidth1: [{ validator: makeFingerValidator('fw1'), trigger: 'change' }],
  636. fingerWidth15: [{ validator: makeFingerValidator('fw15'), trigger: 'change' }],
  637. fingerWidth3: [{ validator: makeFingerValidator('fw3'), trigger: 'change' }],
  638. spineLength: [
  639. { required: true, message: '请输入C7-S4脊柱长度', trigger: 'blur' },
  640. { validator: validateSpineLength, trigger: 'change' }
  641. ]
  642. }
  643. function handleAdd() {
  644. Object.assign(formData, {
  645. id: null, name: '', gender: null, age: null, phone: '',
  646. userType: 1, regionCodes: [], address: '',
  647. shoulderWidth: null, bodyHeight: null, spineLength: null, height: null, weight: null,
  648. fingerWidth1: null, fingerWidth15: null, fingerWidth3: null
  649. })
  650. dialogTitle.value = '新增用户'
  651. dialogVisible.value = true
  652. }
  653. function handleEdit(row) {
  654. Object.assign(formData, {
  655. id: row.id,
  656. name: row.name,
  657. gender: row.gender,
  658. age: row.age,
  659. phone: row.phone,
  660. userType: row.userType ?? 1,
  661. regionCodes: [row.provinceCode, row.cityCode, row.districtCode].filter(c => !!c),
  662. address: row.address || '',
  663. shoulderWidth: row.shoulderWidth,
  664. bodyHeight: row.bodyHeight,
  665. spineLength: row.spineLength,
  666. height: row.height,
  667. weight: row.weight,
  668. fingerWidth1: row.fingerWidth1,
  669. fingerWidth15: row.fingerWidth15,
  670. fingerWidth3: row.fingerWidth3
  671. })
  672. dialogTitle.value = '编辑用户'
  673. dialogVisible.value = true
  674. }
  675. // 性别切换时重新校验指宽字段(避免已填值在新性别下超出范围却无提示)
  676. watch(() => formData.gender, () => {
  677. if (formRef.value) {
  678. formRef.value.validateField(['fingerWidth1', 'fingerWidth15', 'fingerWidth3'])
  679. }
  680. })
  681. async function handleSubmit() {
  682. await formRef.value.validate()
  683. submitLoading.value = true
  684. try {
  685. const codes = formData.regionCodes || []
  686. const payload = {
  687. ...formData,
  688. provinceCode: codes[0] || null,
  689. cityCode: codes[1] || null,
  690. districtCode: codes[2] || null
  691. }
  692. delete payload.regionCodes
  693. if (formData.id) {
  694. await updateProfile(payload)
  695. } else {
  696. await addProfile(payload)
  697. }
  698. ElMessage.success('操作成功')
  699. dialogVisible.value = false
  700. loadData()
  701. } finally {
  702. submitLoading.value = false
  703. }
  704. }
  705. // ======================== 删除 ========================
  706. async function handleDelete(row) {
  707. await ElMessageBox.confirm(`确认删除用户「${row.name}」吗?`, '提示', { type: 'warning' })
  708. await deleteProfile(row.id)
  709. ElMessage.success('删除成功')
  710. loadData()
  711. }
  712. async function handleBatchDelete() {
  713. await ElMessageBox.confirm(`确认批量删除选中的 ${selectedIds.value.length} 条记录吗?`, '提示', { type: 'warning' })
  714. await batchDeleteProfile(selectedIds.value)
  715. ElMessage.success('批量删除成功')
  716. selectedIds.value = []
  717. loadData()
  718. }
  719. // ======================== 导入 ========================
  720. async function handleImport(data) {
  721. // data 为解析后的数组,字段名需与 DTO 对应
  722. await batchImportProfile(data)
  723. }
  724. function onImportSuccess() {
  725. ElMessage.success('导入成功')
  726. loadData()
  727. }
  728. // ======================== 设备管理(跳转独立页面) ========================
  729. /**
  730. * 跳转到用户设备管理页面
  731. */
  732. function handleDeviceManager(row) {
  733. router.push({
  734. path: '/user/device',
  735. query: { userId: row.id, userName: row.name }
  736. })
  737. }
  738. // ======================== 生成用户穴位 ========================
  739. /**
  740. * 手动触发为当前编辑用户生成穴位坐标
  741. * 编辑弹窗中点击「生成用户穴位」按钮时调用
  742. */
  743. async function handleGenerateAcupoint() {
  744. if (!formData.id) {
  745. ElMessage.warning('请先保存用户后再生成穴位')
  746. return
  747. }
  748. generateLoading.value = true
  749. try {
  750. await generateUserAcupoint(formData.id)
  751. ElMessage.success('穴位坐标生成成功')
  752. loadData()
  753. } finally {
  754. generateLoading.value = false
  755. }
  756. }
  757. async function loadUserCategories() {
  758. try {
  759. const res = await getUserCategoryList()
  760. userCategoryOptions.value = res.data || []
  761. } catch (e) {
  762. userCategoryOptions.value = []
  763. }
  764. }
  765. onMounted(() => {
  766. loadUserCategories()
  767. loadData()
  768. })
  769. </script>
  770. <style lang="scss" scoped>
  771. .page-container {
  772. display: flex;
  773. flex-direction: column;
  774. gap: 16px;
  775. }
  776. .search-card {
  777. :deep(.el-card__body) {
  778. padding: 16px 20px 4px;
  779. }
  780. }
  781. .table-card {
  782. .card-header {
  783. display: flex;
  784. align-items: center;
  785. justify-content: space-between;
  786. .title {
  787. font-weight: 600;
  788. font-size: 15px;
  789. }
  790. .btn-group {
  791. display: flex;
  792. gap: 8px;
  793. align-items: center;
  794. }
  795. }
  796. }
  797. .pagination {
  798. margin-top: 16px;
  799. justify-content: flex-end;
  800. }
  801. .text-gray {
  802. color: #aaa;
  803. }
  804. .divider-text {
  805. font-size: 12px;
  806. color: #999;
  807. }
  808. </style>