index.vue 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016
  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.name" placeholder="请输入方案名称" clearable style="width:180px" />
  8. </el-form-item>
  9. <el-form-item label="模式类型">
  10. <el-select v-model="queryForm.modeType" placeholder="全部" clearable style="width:130px">
  11. <el-option label="一键艾灸" :value="1" />
  12. <el-option label="专业模式" :value="2" />
  13. <el-option label="自定义模式" :value="3" />
  14. <el-option label="延年圣手" :value="4" />
  15. </el-select>
  16. </el-form-item>
  17. <el-form-item label="作用类型">
  18. <el-select v-model="queryForm.effectType" placeholder="全部" clearable style="width:110px">
  19. <el-option v-for="e in effectOptions" :key="e" :label="e" :value="e" />
  20. </el-select>
  21. </el-form-item>
  22. <el-form-item label="创作人">
  23. <el-input v-model="queryForm.authorName" placeholder="请输入创作人" clearable style="width:150px" />
  24. </el-form-item>
  25. <el-form-item label="状态">
  26. <el-select v-model="queryForm.status" placeholder="全部" clearable style="width:110px">
  27. <el-option label="草稿" :value="0" />
  28. <el-option label="已发布" :value="1" />
  29. <el-option label="已下架" :value="2" />
  30. </el-select>
  31. </el-form-item>
  32. <el-form-item label="创建时间">
  33. <el-date-picker
  34. v-model="dateRange"
  35. type="daterange"
  36. range-separator="~"
  37. start-placeholder="开始日期"
  38. end-placeholder="结束日期"
  39. value-format="YYYY-MM-DD"
  40. style="width:220px"
  41. />
  42. </el-form-item>
  43. <el-form-item>
  44. <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
  45. <el-button :icon="Refresh" @click="handleReset">重置</el-button>
  46. </el-form-item>
  47. </el-form>
  48. </el-card>
  49. <!-- 表格区域 -->
  50. <el-card class="table-card">
  51. <template #header>
  52. <div class="card-header">
  53. <span class="title">方案列表</span>
  54. <div class="btn-group">
  55. <el-button type="primary" :icon="Plus" @click="handleAdd">新增</el-button>
  56. <el-button
  57. type="success"
  58. :disabled="selectedIds.length === 0"
  59. @click="handleBatchPublish"
  60. >批量发布</el-button>
  61. <el-button
  62. type="warning"
  63. :disabled="selectedIds.length === 0"
  64. @click="handleBatchOffshelf"
  65. >批量下架</el-button>
  66. <el-button
  67. type="danger"
  68. :icon="Delete"
  69. :disabled="selectedIds.length === 0"
  70. @click="handleBatchDelete"
  71. >批量删除</el-button>
  72. </div>
  73. </div>
  74. </template>
  75. <el-table
  76. :data="tableData"
  77. v-loading="loading"
  78. border
  79. stripe
  80. @selection-change="handleSelectionChange"
  81. >
  82. <el-table-column type="selection" width="50" align="center" :selectable="isRowSelectable" />
  83. <el-table-column type="index" label="序号" width="60" align="center" />
  84. <el-table-column prop="planCode" label="方案ID" width="100" />
  85. <el-table-column prop="name" label="方案名称" min-width="140" show-overflow-tooltip />
  86. <el-table-column label="模式类型" width="110" align="center">
  87. <template #default="{ row }">
  88. <el-tag :type="modeTypeTag(row.modeType)" size="small">{{ modeTypeLabel(row.modeType) }}</el-tag>
  89. </template>
  90. </el-table-column>
  91. <el-table-column prop="effectType" label="作用类型" width="90" align="center">
  92. <template #default="{ row }">{{ row.effectType || '-' }}</template>
  93. </el-table-column>
  94. <el-table-column prop="authorName" label="创作人" width="100">
  95. <template #default="{ row }">{{ row.authorName || 'ADMIN' }}</template>
  96. </el-table-column>
  97. <el-table-column label="状态" width="90" align="center">
  98. <template #default="{ row }">
  99. <el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
  100. </template>
  101. </el-table-column>
  102. <el-table-column prop="createTime" label="创建时间" width="170">
  103. <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
  104. </el-table-column>
  105. <el-table-column label="操作" width="220" align="center" fixed="right">
  106. <template #default="{ row }">
  107. <el-button size="small" type="primary" link @click="handleView(row)">查看</el-button>
  108. <el-button size="small" type="warning" link @click="handleEdit(row)">编辑</el-button>
  109. <template v-if="!isSystemPresetPlan(row)">
  110. <el-button v-if="row.status === 0 || row.status === 2" size="small" type="success" link @click="handlePublish(row)">发布</el-button>
  111. <el-button v-if="row.status === 1" size="small" type="info" link @click="handleOffshelf(row)">下架</el-button>
  112. <el-button v-if="row.status === 0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
  113. </template>
  114. </template>
  115. </el-table-column>
  116. </el-table>
  117. <!-- 分页 -->
  118. <el-pagination
  119. v-model:current-page="queryForm.pageNum"
  120. v-model:page-size="queryForm.pageSize"
  121. :page-sizes="[10, 20, 50]"
  122. :total="total"
  123. :hide-on-single-page="false"
  124. layout="total, sizes, prev, pager, next, jumper"
  125. background
  126. class="pagination"
  127. @size-change="loadData"
  128. @current-change="loadData"
  129. />
  130. </el-card>
  131. <!-- 新增/编辑弹窗 -->
  132. <el-dialog
  133. v-model="dialogVisible"
  134. :title="dialogTitle"
  135. width="780px"
  136. destroy-on-close
  137. :close-on-click-modal="false"
  138. >
  139. <el-form
  140. :model="formData"
  141. :rules="formRules"
  142. ref="formRef"
  143. label-width="110px"
  144. :disabled="dialogMode === 'view'"
  145. >
  146. <el-row :gutter="20">
  147. <el-col :span="12">
  148. <el-form-item label="方案名称" prop="name">
  149. <el-input
  150. v-model="formData.name"
  151. placeholder="请输入方案名称"
  152. maxlength="100"
  153. :disabled="isPlanNameLocked"
  154. />
  155. </el-form-item>
  156. </el-col>
  157. <el-col :span="12">
  158. <el-form-item label="模式类型" prop="modeType">
  159. <el-select
  160. v-model="formData.modeType"
  161. placeholder="请选择模式类型"
  162. style="width:100%"
  163. :disabled="isModeTypeLocked"
  164. @change="handleModeTypeChange"
  165. >
  166. <el-option
  167. v-for="item in editableModeTypeOptions"
  168. :key="item.value"
  169. :label="item.label"
  170. :value="item.value"
  171. />
  172. </el-select>
  173. </el-form-item>
  174. </el-col>
  175. </el-row>
  176. <el-row :gutter="20">
  177. <el-col v-if="showEffectTypeField" :span="12">
  178. <el-form-item label="作用类型" prop="effectType">
  179. <el-select
  180. v-model="formData.effectType"
  181. placeholder="请选择作用"
  182. :clearable="!isEffectTypeLocked"
  183. :disabled="isEffectTypeLocked"
  184. style="width:100%"
  185. >
  186. <el-option v-for="e in effectOptions" :key="e" :label="e" :value="e" />
  187. </el-select>
  188. </el-form-item>
  189. </el-col>
  190. <el-col :span="12">
  191. <el-form-item label="适用人群" prop="applicableGender">
  192. <el-radio-group v-model="formData.applicableGender">
  193. <el-radio :label="0">不限</el-radio>
  194. <el-radio :label="1">仅男</el-radio>
  195. <el-radio :label="2">仅女</el-radio>
  196. </el-radio-group>
  197. </el-form-item>
  198. </el-col>
  199. </el-row>
  200. <el-row :gutter="20">
  201. <el-col :span="12">
  202. <el-form-item label="适用年龄" prop="ageMin">
  203. <el-input-number v-model="formData.ageMin" :min="1" :max="formData.ageMax - 1" style="width:110px" />
  204. 岁 ~
  205. <el-input-number v-model="formData.ageMax" :min="formData.ageMin + 1" style="width:110px" />
  206. </el-form-item>
  207. </el-col>
  208. <el-col :span="12">
  209. <el-form-item label="创作人" prop="authorName">
  210. <el-input v-model="formData.authorName" disabled placeholder="当前登录用户" />
  211. </el-form-item>
  212. </el-col>
  213. </el-row>
  214. <el-form-item label="适用症状" prop="symptomsList">
  215. <el-checkbox-group v-model="formData.symptomsList" class="symptom-checkboxes">
  216. <el-checkbox v-for="s in symptomsOptions" :key="s" :label="s" />
  217. </el-checkbox-group>
  218. <div v-if="dialogMode !== 'view'" class="symptom-add-row">
  219. <el-input
  220. v-model="customSymptom"
  221. placeholder="输入症状名称"
  222. maxlength="20"
  223. clearable
  224. @keyup.enter="addCustomSymptom"
  225. />
  226. <el-button type="primary" :icon="Plus" @click="addCustomSymptom">添加</el-button>
  227. </div>
  228. </el-form-item>
  229. <el-form-item label="方案描述">
  230. <el-input v-model="formData.description" type="textarea" :rows="2" maxlength="500" show-word-limit />
  231. </el-form-item>
  232. <!-- 灸方参数 -->
  233. <el-divider>灸方参数</el-divider>
  234. <div v-for="(step, index) in formData.steps" :key="index" class="step-row">
  235. <div class="step-header">
  236. <span class="step-num">参数 {{ index + 1 }}</span>
  237. <div v-if="dialogMode !== 'view'" class="step-actions">
  238. <el-tooltip content="上移参数" placement="top">
  239. <el-button
  240. size="small"
  241. :icon="ArrowUp"
  242. link
  243. :disabled="index === 0"
  244. aria-label="上移参数"
  245. @click="moveStep(index, -1)"
  246. />
  247. </el-tooltip>
  248. <el-tooltip content="下移参数" placement="top">
  249. <el-button
  250. size="small"
  251. :icon="ArrowDown"
  252. link
  253. :disabled="index === formData.steps.length - 1"
  254. aria-label="下移参数"
  255. @click="moveStep(index, 1)"
  256. />
  257. </el-tooltip>
  258. <el-button
  259. size="small"
  260. type="danger"
  261. link
  262. :disabled="formData.steps.length <= 1"
  263. @click="removeStep(index)"
  264. >删除</el-button>
  265. </div>
  266. </div>
  267. <el-row :gutter="12">
  268. <el-col :span="8">
  269. <el-form-item :label="'穴位'" :prop="`steps.${index}.acupointId`" :rules="stepRules.acupointId">
  270. <el-select
  271. v-model="step.acupointId"
  272. placeholder="请选择穴位"
  273. style="width:100%"
  274. filterable
  275. @change="(val) => onAcupointChange(val, index)"
  276. >
  277. <el-option
  278. v-for="a in acupointOptions"
  279. :key="a.id"
  280. :label="a.name"
  281. :value="a.id"
  282. />
  283. </el-select>
  284. </el-form-item>
  285. </el-col>
  286. <el-col :span="8">
  287. <el-form-item :label="'侧别'" :prop="`steps.${index}.side`" :rules="stepRules.side">
  288. <el-select v-model="step.side" placeholder="侧别" style="width:100%">
  289. <el-option label="中心" :value="1" />
  290. <el-option label="左侧" :value="2" />
  291. <el-option label="右侧" :value="3" />
  292. <el-option label="双侧" :value="4" />
  293. </el-select>
  294. </el-form-item>
  295. </el-col>
  296. <el-col :span="8">
  297. <el-form-item :label="'手法'" :prop="`steps.${index}.techniqueId`" :rules="stepRules.techniqueId">
  298. <el-select
  299. v-model="step.techniqueId"
  300. placeholder="请选择手法"
  301. style="width:100%"
  302. filterable
  303. @change="(val) => onTechniqueChange(val, index)"
  304. >
  305. <el-option
  306. v-for="t in techniqueOptions"
  307. :key="t.id"
  308. :label="t.name"
  309. :value="t.id"
  310. />
  311. </el-select>
  312. </el-form-item>
  313. </el-col>
  314. </el-row>
  315. <el-row :gutter="12">
  316. <el-col :span="8">
  317. <el-form-item :label="'温度(°C)'" :prop="`steps.${index}.temperature`" :rules="stepRules.temperature">
  318. <el-input-number v-model="step.temperature" :min="35" :max="50" :precision="1" style="width:100%" />
  319. </el-form-item>
  320. </el-col>
  321. <el-col :span="8">
  322. <el-form-item :label="'时间(分)'" :prop="`steps.${index}.duration`" :rules="stepRules.duration">
  323. <el-input-number v-model="step.duration" :min="1" :max="30" style="width:100%" />
  324. </el-form-item>
  325. </el-col>
  326. <el-col :span="8">
  327. <el-form-item label="备注">
  328. <el-input v-model="step.remark" placeholder="备注(选填)" maxlength="100" />
  329. </el-form-item>
  330. </el-col>
  331. </el-row>
  332. </div>
  333. <el-button
  334. v-if="dialogMode !== 'view'"
  335. type="primary"
  336. plain
  337. size="small"
  338. @click="addStep"
  339. style="margin-top:4px"
  340. >+ 添加参数</el-button>
  341. </el-form>
  342. <template #footer>
  343. <el-button @click="dialogVisible = false">{{ dialogMode === 'view' ? '关闭' : '取消' }}</el-button>
  344. <template v-if="dialogMode !== 'view'">
  345. <el-button v-if="!isSystemPresetPlan(formData)" :loading="submitLoading" @click="handleSubmit(0)">保存为草稿</el-button>
  346. <el-button type="primary" :loading="submitLoading" @click="handleSubmit(isSystemPresetPlan(formData) ? formData.status : 1)">
  347. {{ isSystemPresetPlan(formData) ? '保存修改' : '发布方案' }}
  348. </el-button>
  349. </template>
  350. </template>
  351. </el-dialog>
  352. <!-- 方案详情弹窗(只读) -->
  353. <el-dialog v-model="detailVisible" title="方案详情" width="700px" destroy-on-close :close-on-click-modal="false">
  354. <template v-if="detailData">
  355. <el-descriptions :column="2" border size="small">
  356. <el-descriptions-item label="方案编码">{{ detailData.planCode }}</el-descriptions-item>
  357. <el-descriptions-item label="方案名称">{{ detailData.name }}</el-descriptions-item>
  358. <el-descriptions-item label="模式类型">{{ modeTypeLabel(detailData.modeType) }}</el-descriptions-item>
  359. <el-descriptions-item label="作用类型">{{ detailData.effectType || '-' }}</el-descriptions-item>
  360. <el-descriptions-item label="创作人">{{ detailData.authorName || 'ADMIN' }}</el-descriptions-item>
  361. <el-descriptions-item label="状态">{{ statusLabel(detailData.status) }}</el-descriptions-item>
  362. <el-descriptions-item label="适用人群">{{ ['不限', '仅男', '仅女'][detailData.applicableGender] || '-' }}</el-descriptions-item>
  363. <el-descriptions-item label="适用年龄">{{ detailData.ageMin }}-{{ detailData.ageMax }}岁</el-descriptions-item>
  364. <el-descriptions-item label="适用症状" :span="2">{{ detailData.symptoms || '-' }}</el-descriptions-item>
  365. <el-descriptions-item label="方案描述" :span="2">{{ detailData.description || '-' }}</el-descriptions-item>
  366. <el-descriptions-item label="使用人数">{{ detailData.useCount || 0 }}人</el-descriptions-item>
  367. <el-descriptions-item label="平均评分">{{ detailData.avgRating ?? '-' }}分</el-descriptions-item>
  368. <el-descriptions-item label="创建时间">{{ formatDateTime(detailData.createTime) }}</el-descriptions-item>
  369. <el-descriptions-item label="更新时间">{{ formatDateTime(detailData.updateTime) }}</el-descriptions-item>
  370. </el-descriptions>
  371. <el-divider>灸方参数</el-divider>
  372. <el-table :data="detailData.steps || []" border size="small">
  373. <el-table-column prop="stepOrder" label="参数" width="60" align="center" />
  374. <el-table-column prop="acupointName" label="穴位" width="100" />
  375. <el-table-column label="侧别" width="80" align="center">
  376. <template #default="{ row }">{{ ['', '中心', '左侧', '右侧', '双侧'][row.side] || '-' }}</template>
  377. </el-table-column>
  378. <el-table-column prop="temperature" label="温度(°C)" width="90" align="center" />
  379. <el-table-column prop="duration" label="时间(分)" width="90" align="center" />
  380. <el-table-column prop="techniqueName" label="艾灸手法" width="100" />
  381. <el-table-column prop="remark" label="备注" min-width="100">
  382. <template #default="{ row }">{{ row.remark || '-' }}</template>
  383. </el-table-column>
  384. </el-table>
  385. </template>
  386. <template #footer>
  387. <el-button @click="detailVisible = false">关闭</el-button>
  388. </template>
  389. </el-dialog>
  390. </div>
  391. </template>
  392. <script setup>
  393. import { ref, reactive, computed, onMounted } from 'vue'
  394. import { ElMessage, ElMessageBox } from 'element-plus'
  395. import { Search, Refresh, Plus, Delete, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
  396. import {
  397. getPlanPage,
  398. getPlanDetail,
  399. addPlan,
  400. updatePlan,
  401. deletePlan,
  402. batchDeletePlan,
  403. publishPlan,
  404. batchPublishPlan,
  405. offshelfPlan,
  406. batchOffshelfPlan
  407. } from '@/api/plan'
  408. import { getAcupointList } from '@/api/acupoint'
  409. import { getTechniqueList } from '@/api/moxibustion'
  410. import { useUserStore } from '@/store/user'
  411. import { formatDateTime } from '@/utils/date'
  412. import { parsePageData } from '@/utils/pagination'
  413. // ============ 枚举常量 ============
  414. const effectOptions = ['驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳']
  415. const modeTypeOptions = [
  416. { label: '一键艾灸', value: 1 },
  417. { label: '专业模式', value: 2 },
  418. { label: '自定义模式', value: 3 },
  419. { label: '延年圣手', value: 4 }
  420. ]
  421. const addModeTypeOptions = modeTypeOptions.filter(item => [3, 4].includes(item.value))
  422. const defaultSymptomsOptions = ['怕冷', '感冒', '咳嗽', '痰多', '疼痛', '腰酸', '失眠', '疲劳', '皮肤暗沉', '手脚冰凉']
  423. const symptomsOptions = ref([...defaultSymptomsOptions])
  424. const systemPresetPlanCodes = Array.from({ length: 9 }, (_, index) => `S00${index + 1}`)
  425. const professionalPresetEffectMap = {
  426. S002: '驱寒',
  427. S003: '祛湿',
  428. S004: '祛风',
  429. S005: '化瘀',
  430. S006: '活血',
  431. S007: '化痰',
  432. S008: '养颜',
  433. S009: '扶阳'
  434. }
  435. const userStore = useUserStore()
  436. // ============ 选项列表(用于选择框) ============
  437. const acupointOptions = ref([])
  438. const techniqueOptions = ref([])
  439. /**
  440. * 加载穴位、手法选项列表
  441. */
  442. async function loadOptions() {
  443. try {
  444. const [acupointRes, techniqueRes] = await Promise.all([
  445. getAcupointList(),
  446. getTechniqueList()
  447. ])
  448. acupointOptions.value = acupointRes.data || []
  449. techniqueOptions.value = techniqueRes.data || []
  450. } catch (e) {
  451. // 选项加载失败不影响主流程
  452. }
  453. }
  454. /**
  455. * 模式类型标签文字
  456. */
  457. function modeTypeLabel(val) {
  458. return ['', '一键艾灸', '专业模式', '自定义模式', '延年圣手'][val] || '-'
  459. }
  460. /**
  461. * 模式类型 Tag 颜色
  462. */
  463. function modeTypeTag(val) {
  464. return ['', '', 'success', 'warning', 'danger'][val] || ''
  465. }
  466. /**
  467. * 状态标签文字
  468. */
  469. function statusLabel(val) {
  470. return { 0: '草稿', 1: '已发布', 2: '已下架' }[val] ?? '-'
  471. }
  472. /**
  473. * 状态 Tag 颜色
  474. */
  475. function statusTag(val) {
  476. return { 0: 'info', 1: 'success', 2: 'warning' }[val] ?? ''
  477. }
  478. function isSystemPresetPlan(plan) {
  479. return systemPresetPlanCodes.includes(plan?.planCode)
  480. }
  481. function isOneClickPresetPlan(plan) {
  482. return plan?.planCode === 'S001'
  483. }
  484. function isProfessionalPresetPlan(plan) {
  485. return Boolean(professionalPresetEffectMap[plan?.planCode])
  486. }
  487. function getProfessionalPresetEffect(plan) {
  488. return professionalPresetEffectMap[plan?.planCode] || ''
  489. }
  490. function isRowSelectable(row) {
  491. return !isSystemPresetPlan(row)
  492. }
  493. // ============ 查询 ============
  494. const queryForm = reactive({
  495. name: '',
  496. modeType: null,
  497. effectType: '',
  498. authorName: '',
  499. status: null,
  500. createTimeStart: '',
  501. createTimeEnd: '',
  502. pageNum: 1,
  503. pageSize: 10
  504. })
  505. const dateRange = ref([])
  506. const tableData = ref([])
  507. const total = ref(0)
  508. const loading = ref(false)
  509. const selectedIds = ref([])
  510. /**
  511. * 加载方案列表
  512. */
  513. async function loadData() {
  514. loading.value = true
  515. // 同步日期范围
  516. queryForm.createTimeStart = dateRange.value?.[0] || ''
  517. queryForm.createTimeEnd = dateRange.value?.[1] || ''
  518. try {
  519. const res = await getPlanPage(queryForm)
  520. const { records, total: pageTotal } = parsePageData(res.data)
  521. tableData.value = records
  522. total.value = pageTotal
  523. } finally {
  524. loading.value = false
  525. }
  526. }
  527. /**
  528. * 查询
  529. */
  530. function handleSearch() {
  531. queryForm.pageNum = 1
  532. loadData()
  533. }
  534. /**
  535. * 重置查询条件
  536. */
  537. function handleReset() {
  538. queryForm.name = ''
  539. queryForm.modeType = null
  540. queryForm.effectType = ''
  541. queryForm.authorName = ''
  542. queryForm.status = null
  543. queryForm.createTimeStart = ''
  544. queryForm.createTimeEnd = ''
  545. queryForm.pageNum = 1
  546. dateRange.value = []
  547. loadData()
  548. }
  549. /**
  550. * 多选变化
  551. */
  552. function handleSelectionChange(selection) {
  553. selectedIds.value = selection.map(r => r.id)
  554. }
  555. // ============ 新增/编辑弹窗 ============
  556. const dialogVisible = ref(false)
  557. const dialogTitle = ref('')
  558. const dialogMode = ref('add')
  559. const submitLoading = ref(false)
  560. const formRef = ref(null)
  561. const customSymptom = ref('')
  562. const defaultStep = () => ({
  563. acupointId: null,
  564. acupointName: '',
  565. side: 1,
  566. temperature: 42,
  567. duration: 5,
  568. techniqueId: null,
  569. techniqueName: '',
  570. remark: ''
  571. })
  572. const defaultForm = () => ({
  573. id: null,
  574. planCode: '',
  575. name: '',
  576. modeType: 3,
  577. effectType: '',
  578. symptomsList: [],
  579. symptoms: '',
  580. applicableGender: 0,
  581. ageMin: 1,
  582. ageMax: 120,
  583. authorId: getCurrentAuthorId(),
  584. authorName: getCurrentAuthorName(),
  585. authorType: getCurrentAuthorType(),
  586. status: 0,
  587. description: '',
  588. steps: [defaultStep()]
  589. })
  590. const formData = reactive(defaultForm())
  591. const formRules = {
  592. name: [{ required: true, message: '请输入方案名称', trigger: 'blur' }],
  593. modeType: [{ required: true, message: '请选择模式类型', trigger: 'change' }],
  594. effectType: [
  595. {
  596. validator: (_rule, value, callback) => {
  597. if (formData.modeType === 2 && !value) {
  598. callback(new Error('请选择作用类型'))
  599. return
  600. }
  601. callback()
  602. },
  603. trigger: 'change'
  604. }
  605. ]
  606. }
  607. const stepRules = {
  608. acupointId: [{ required: true, message: '请选择穴位', trigger: 'change' }],
  609. side: [{ required: true, message: '请选择侧别', trigger: 'change' }],
  610. temperature: [{ required: true, message: '请填写温度', trigger: 'blur' }],
  611. duration: [{ required: true, message: '请填写时间', trigger: 'blur' }],
  612. techniqueId: [{ required: true, message: '请选择艾灸手法', trigger: 'change' }]
  613. }
  614. /**
  615. * 穴位选择变更:同步填充 acupointName
  616. */
  617. function onAcupointChange(id, index) {
  618. const found = acupointOptions.value.find(a => a.id === id)
  619. formData.steps[index].acupointName = found ? found.name : ''
  620. }
  621. /**
  622. * 手法选择变更:同步填充 techniqueName
  623. */
  624. function onTechniqueChange(id, index) {
  625. const found = techniqueOptions.value.find(t => t.id === id)
  626. formData.steps[index].techniqueName = found ? found.name : ''
  627. }
  628. function parseSymptoms(symptoms) {
  629. return (symptoms || '')
  630. .split(',')
  631. .map(item => item.trim())
  632. .filter(Boolean)
  633. .filter((item, index, list) => list.indexOf(item) === index)
  634. }
  635. function syncSymptomOptions(symptoms) {
  636. symptoms.forEach(item => {
  637. if (!symptomsOptions.value.includes(item)) {
  638. symptomsOptions.value.push(item)
  639. }
  640. })
  641. }
  642. function addCustomSymptom() {
  643. const symptom = customSymptom.value.trim()
  644. if (!symptom) {
  645. ElMessage.warning('请输入症状名称')
  646. return
  647. }
  648. if (/[,,]/.test(symptom)) {
  649. ElMessage.warning('症状名称不能包含逗号')
  650. return
  651. }
  652. syncSymptomOptions([symptom])
  653. if (!formData.symptomsList.includes(symptom)) {
  654. formData.symptomsList.push(symptom)
  655. }
  656. customSymptom.value = ''
  657. }
  658. function getCurrentAuthorId() {
  659. return userStore.userInfo?.userId || null
  660. }
  661. function getCurrentAuthorName() {
  662. return userStore.nickname || userStore.username || 'ADMIN'
  663. }
  664. function getCurrentAuthorType() {
  665. const typeMap = { 1: 2, 2: 3, 3: 1 }
  666. return typeMap[userStore.userInfo?.userType] || 1
  667. }
  668. function applyCurrentAuthor() {
  669. formData.authorId = getCurrentAuthorId()
  670. formData.authorName = getCurrentAuthorName()
  671. formData.authorType = getCurrentAuthorType()
  672. }
  673. const editableModeTypeOptions = computed(() => {
  674. if (dialogMode.value === 'add') {
  675. return addModeTypeOptions
  676. }
  677. if (isOneClickPresetPlan(formData)) {
  678. return modeTypeOptions.filter(item => item.value === 1)
  679. }
  680. if (isProfessionalPresetPlan(formData)) {
  681. return modeTypeOptions.filter(item => item.value === 2)
  682. }
  683. return modeTypeOptions
  684. })
  685. const isModeTypeLocked = computed(() => (
  686. dialogMode.value === 'edit' && (isOneClickPresetPlan(formData) || isProfessionalPresetPlan(formData))
  687. ))
  688. const isPlanNameLocked = computed(() => dialogMode.value === 'edit' && isSystemPresetPlan(formData))
  689. const isEffectTypeLocked = computed(() => dialogMode.value === 'edit' && isProfessionalPresetPlan(formData))
  690. const showEffectTypeField = computed(() => formData.modeType === 2 && !isOneClickPresetPlan(formData))
  691. function applyPlanModeRules() {
  692. if (dialogMode.value === 'add' && ![3, 4].includes(formData.modeType)) {
  693. formData.modeType = 3
  694. }
  695. if (isOneClickPresetPlan(formData)) {
  696. formData.modeType = 1
  697. formData.effectType = ''
  698. return
  699. }
  700. if (isProfessionalPresetPlan(formData)) {
  701. formData.modeType = 2
  702. formData.effectType = getProfessionalPresetEffect(formData)
  703. return
  704. }
  705. if (formData.modeType !== 2) {
  706. formData.effectType = ''
  707. }
  708. }
  709. function handleModeTypeChange() {
  710. applyPlanModeRules()
  711. }
  712. /**
  713. * 添加一个灸方参数
  714. */
  715. function addStep() {
  716. formData.steps.push(defaultStep())
  717. }
  718. /**
  719. * 调整灸方参数顺序
  720. */
  721. function moveStep(index, direction) {
  722. const targetIndex = index + direction
  723. if (targetIndex < 0 || targetIndex >= formData.steps.length) return
  724. const [step] = formData.steps.splice(index, 1)
  725. formData.steps.splice(targetIndex, 0, step)
  726. formRef.value?.clearValidate?.()
  727. }
  728. /**
  729. * 删除某个灸方参数
  730. */
  731. function removeStep(index) {
  732. if (formData.steps.length <= 1) return
  733. formData.steps.splice(index, 1)
  734. formRef.value?.clearValidate?.()
  735. }
  736. /**
  737. * 打开新增弹窗
  738. */
  739. function handleAdd() {
  740. Object.assign(formData, defaultForm())
  741. formData.steps = [defaultStep()]
  742. customSymptom.value = ''
  743. applyCurrentAuthor()
  744. dialogTitle.value = '新增方案'
  745. dialogMode.value = 'add'
  746. applyPlanModeRules()
  747. dialogVisible.value = true
  748. }
  749. /**
  750. * 打开编辑弹窗
  751. */
  752. async function handleEdit(row) {
  753. const res = await getPlanDetail(row.id)
  754. const detail = res.data
  755. const symptomsList = parseSymptoms(detail.symptoms)
  756. syncSymptomOptions(symptomsList)
  757. Object.assign(formData, defaultForm(), {
  758. ...detail,
  759. symptomsList,
  760. steps: detail.steps?.length > 0 ? detail.steps : [defaultStep()]
  761. })
  762. customSymptom.value = ''
  763. applyCurrentAuthor()
  764. dialogTitle.value = '编辑方案'
  765. dialogMode.value = 'edit'
  766. applyPlanModeRules()
  767. dialogVisible.value = true
  768. }
  769. /**
  770. * 提交保存(0=草稿,1=发布)
  771. */
  772. async function handleSubmit(publishFlag) {
  773. applyPlanModeRules()
  774. await formRef.value.validate()
  775. applyCurrentAuthor()
  776. submitLoading.value = true
  777. try {
  778. const payload = {
  779. ...formData,
  780. symptoms: formData.symptomsList.join(','),
  781. status: publishFlag
  782. }
  783. if (formData.id) {
  784. await updatePlan(payload)
  785. } else {
  786. await addPlan(payload)
  787. }
  788. ElMessage.success('操作成功')
  789. dialogVisible.value = false
  790. loadData()
  791. } finally {
  792. submitLoading.value = false
  793. }
  794. }
  795. // ============ 方案详情弹窗 ============
  796. const detailVisible = ref(false)
  797. const detailData = ref(null)
  798. /**
  799. * 查看方案详情
  800. */
  801. async function handleView(row) {
  802. const res = await getPlanDetail(row.id)
  803. detailData.value = res.data
  804. detailVisible.value = true
  805. }
  806. // ============ 状态操作 ============
  807. /**
  808. * 发布单条方案
  809. */
  810. async function handlePublish(row) {
  811. await ElMessageBox.confirm(`确认发布方案「${row.name}」吗?`, '提示', { type: 'warning' })
  812. await publishPlan(row.id)
  813. ElMessage.success('发布成功')
  814. loadData()
  815. }
  816. /**
  817. * 下架单条方案
  818. */
  819. async function handleOffshelf(row) {
  820. await ElMessageBox.confirm(`确认下架方案「${row.name}」吗?`, '提示', { type: 'warning' })
  821. await offshelfPlan(row.id)
  822. ElMessage.success('下架成功')
  823. loadData()
  824. }
  825. /**
  826. * 批量发布
  827. */
  828. async function handleBatchPublish() {
  829. await ElMessageBox.confirm(`确认批量发布选中的 ${selectedIds.value.length} 条方案吗?`, '批量发布', { type: 'warning' })
  830. await batchPublishPlan(selectedIds.value)
  831. ElMessage.success('批量发布成功')
  832. loadData()
  833. }
  834. /**
  835. * 批量下架
  836. */
  837. async function handleBatchOffshelf() {
  838. await ElMessageBox.confirm(`确认批量下架选中的 ${selectedIds.value.length} 条方案吗?`, '批量下架', { type: 'warning' })
  839. await batchOffshelfPlan(selectedIds.value)
  840. ElMessage.success('批量下架成功')
  841. loadData()
  842. }
  843. /**
  844. * 删除单条方案
  845. */
  846. async function handleDelete(row) {
  847. await ElMessageBox.confirm(`确认删除方案「${row.name}」吗?`, '提示', { type: 'warning' })
  848. await deletePlan(row.id)
  849. ElMessage.success('删除成功')
  850. loadData()
  851. }
  852. /**
  853. * 批量删除
  854. */
  855. async function handleBatchDelete() {
  856. await ElMessageBox.confirm(
  857. `确认批量删除选中的 ${selectedIds.value.length} 条方案吗?(仅草稿状态可删)`,
  858. '批量删除确认',
  859. { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
  860. )
  861. await batchDeletePlan(selectedIds.value)
  862. ElMessage.success('批量删除成功')
  863. selectedIds.value = []
  864. loadData()
  865. }
  866. onMounted(() => {
  867. loadData()
  868. loadOptions()
  869. })
  870. </script>
  871. <style lang="scss" scoped>
  872. .page-container {
  873. display: flex;
  874. flex-direction: column;
  875. gap: 16px;
  876. }
  877. .search-card {
  878. :deep(.el-card__body) {
  879. padding: 16px 20px 4px;
  880. }
  881. }
  882. .table-card {
  883. .card-header {
  884. display: flex;
  885. align-items: center;
  886. justify-content: space-between;
  887. .title {
  888. font-weight: 600;
  889. font-size: 15px;
  890. }
  891. .btn-group {
  892. display: flex;
  893. gap: 8px;
  894. align-items: center;
  895. }
  896. }
  897. }
  898. .pagination {
  899. margin-top: 16px;
  900. justify-content: flex-end;
  901. }
  902. .symptom-checkboxes {
  903. display: flex;
  904. flex-wrap: wrap;
  905. gap: 0 20px;
  906. }
  907. .symptom-add-row {
  908. display: flex;
  909. gap: 8px;
  910. width: 360px;
  911. max-width: 100%;
  912. margin-top: 8px;
  913. }
  914. .step-row {
  915. border: 1px solid #ebeef5;
  916. border-radius: 6px;
  917. padding: 12px 16px 0;
  918. margin-bottom: 12px;
  919. background: #fafafa;
  920. .step-header {
  921. display: flex;
  922. align-items: center;
  923. justify-content: space-between;
  924. margin-bottom: 8px;
  925. .step-num {
  926. font-weight: 600;
  927. color: #409eff;
  928. font-size: 13px;
  929. }
  930. .step-actions {
  931. display: flex;
  932. align-items: center;
  933. gap: 8px;
  934. :deep(.el-button + .el-button) {
  935. margin-left: 0;
  936. }
  937. }
  938. }
  939. }
  940. </style>