index.vue 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068
  1. <template>
  2. <div class="page-container">
  3. <el-card shadow="never">
  4. <template #header>
  5. <div class="card-header">
  6. <span class="title">用户管理</span>
  7. <div class="header-actions">
  8. <el-cascader
  9. v-model="searchAreaValue"
  10. :options="areaTreeOptions"
  11. :props="{ value: 'id', label: 'name' }"
  12. placeholder="所属站点"
  13. style="width: 240px; margin-right: 10px"
  14. clearable
  15. @change="onSearchAreaChange"
  16. />
  17. <el-input v-model="searchForm.keyword" placeholder="搜索姓名/手机号" style="width: 200px; margin-right: 10px;" clearable @keyup.enter="handleSearch" @clear="handleSearch" />
  18. <el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['archieves:customer:add']">新增用户</el-button>
  19. </div>
  20. </div>
  21. </template>
  22. <el-table :data="tableData" v-loading="loading" style="width: 100%" :header-cell-style="{ background: '#f5f7fa' }">
  23. <el-table-column label="用户基本信息" width="250">
  24. <template #default="scope">
  25. <div style="display: flex; align-items: center;">
  26. <el-avatar :size="40" :src="scope.row.avatarUrl" style="margin-right: 10px;" />
  27. <div>
  28. <div style="font-weight: bold;">{{ scope.row.name }}
  29. <dict-tag :options="sys_user_sex" :value="scope.row.gender" />
  30. </div>
  31. <div style="font-size: 12px; color: #999;">{{ scope.row.phone }}</div>
  32. </div>
  33. </div>
  34. </template>
  35. </el-table-column>
  36. <el-table-column label="住址" show-overflow-tooltip min-width="150">
  37. <template #default="scope">
  38. {{ [scope.row.regionCode ? scope.row.regionCode.split('/').map(c => codeToName(c)).filter(Boolean).join(' ') : '', scope.row.address].filter(Boolean).join(' ') || '-' }}
  39. </template>
  40. </el-table-column>
  41. <el-table-column label="用户标签" width="200">
  42. <template #default="scope">
  43. <el-tag v-for="tag in scope.row.tags" :key="tag.id" :type="tag.colorType || 'info'" effect="light" size="small" style="margin-right: 5px;">{{ tag.name }}</el-tag>
  44. </template>
  45. </el-table-column>
  46. <el-table-column label="录入信息" width="200">
  47. <template #default="scope">
  48. <div><el-tag size="small" effect="plain" :type="scope.row.tenantName ? '' : 'warning'">{{ scope.row.tenantName || '-' }}</el-tag></div>
  49. <div style="font-size: 12px; color: #999; margin-top: 4px;">{{ scope.row.createTime }}</div>
  50. </template>
  51. </el-table-column>
  52. <el-table-column label="订单数量" width="120" align="center" sortable prop="orderCount">
  53. <template #default="scope">
  54. <div>{{ scope.row.orderCount }}单</div>
  55. </template>
  56. </el-table-column>
  57. <el-table-column prop="petCount" label="关联宠物" width="100" align="center">
  58. <template #default="scope">
  59. <el-tag size="small" round>{{ scope.row.petCount }}只</el-tag>
  60. </template>
  61. </el-table-column>
  62. <el-table-column label="状态" width="100" align="center">
  63. <template #default="scope">
  64. <el-switch
  65. v-model="scope.row.status"
  66. :active-value="0"
  67. :inactive-value="1"
  68. inline-prompt
  69. active-text="正常"
  70. inactive-text="停用"
  71. @change="handleStatusChange(scope.row)"
  72. />
  73. </template>
  74. </el-table-column>
  75. <el-table-column prop="remark" label="备注" show-overflow-tooltip min-width="150" />
  76. <el-table-column label="操作" width="200" align="center">
  77. <template #default="scope">
  78. <el-button link type="primary" size="small" @click="handleDetail(scope.row)" v-hasPermi="['archieves:customer:query']">详情</el-button>
  79. <el-button link type="primary" size="small" @click="handleEdit(scope.row)" v-hasPermi="['archieves:customer:edit']">编辑</el-button>
  80. <el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, scope.row)" style="margin-left: 10px; vertical-align: middle">
  81. <el-button link type="primary" size="small">
  82. 更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
  83. </el-button>
  84. <template #dropdown>
  85. <el-dropdown-menu>
  86. <el-dropdown-item command="remark" v-hasPermi="['archieves:customer:remark']">添加备注</el-dropdown-item>
  87. <el-dropdown-item command="delete" style="color: #F56C6C" v-hasPermi="['archieves:customer:remove']">删除用户</el-dropdown-item>
  88. </el-dropdown-menu>
  89. </template>
  90. </el-dropdown>
  91. </template>
  92. </el-table-column>
  93. </el-table>
  94. <div class="pagination-container">
  95. <el-pagination
  96. v-model:current-page="queryParams.pageNum"
  97. v-model:page-size="queryParams.pageSize"
  98. :page-sizes="[10, 20, 50, 100]"
  99. layout="total, sizes, prev, pager, next, jumper"
  100. :total="total"
  101. @size-change="getList"
  102. @current-change="getList"
  103. />
  104. </div>
  105. </el-card>
  106. <!-- User Detail Drawer -->
  107. <CustomerDetailDrawer
  108. ref="customerDetailRef"
  109. v-model:visible="drawerVisible"
  110. :customer-id="currentUser.id"
  111. editable
  112. :service-list="serviceList"
  113. :area-station-list="allNodes"
  114. @add-pet="openAddPet"
  115. @pet-detail="handlePetDetail"
  116. @pet-edit="handlePetEdit"
  117. @pet-remark="handlePetRemark"
  118. @pet-delete="handlePetDelete"
  119. />
  120. <!-- Pet Detail Drawer -->
  121. <PetDetailDrawer
  122. v-model:visible="petDrawerVisible"
  123. :pet-id="currentPet.id"
  124. :service-list="serviceList"
  125. @remark-saved="getList"
  126. />
  127. <!-- Add/Edit User Dialog -->
  128. <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '新增用户'" width="700px" destroy-on-close>
  129. <el-form :model="form" label-width="90px" class="user-form">
  130. <el-row :gutter="20">
  131. <el-col :span="24" style="text-align: center; margin-bottom: 25px;">
  132. <el-upload action="#" :show-file-list="false" :auto-upload="false" :on-change="handleUserUploadFile">
  133. <el-avatar :size="80" :src="userAvatarDisplayUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" class="upload-avatar" />
  134. <div style="margin-top: 8px; font-size: 12px; color: #409EFF;">点击修改头像</div>
  135. </el-upload>
  136. </el-col>
  137. <el-col :span="24"><div class="form-section-header">基本资料</div></el-col>
  138. <el-col :span="12">
  139. <el-form-item label="所属品牌" required>
  140. <PageSelect v-model="form.tenantId"
  141. :options="brandList.map(item => ({ value: item.tenantId, label: item.name }))"
  142. :total="brandTotal" :pageSize="10" placeholder="请选择所属品牌"
  143. @page-change="handleBrandPageChange"
  144. @visible-change="handleBrandVisibleChange" />
  145. </el-form-item>
  146. </el-col>
  147. <el-col :span="12">
  148. <el-form-item label="姓名" required><el-input v-model="form.name" placeholder="请输入姓名" /></el-form-item>
  149. </el-col>
  150. <el-col :span="12">
  151. <el-form-item label="电话" required><el-input v-model="form.phone" placeholder="请输入电话" /></el-form-item>
  152. </el-col>
  153. <el-col :span="12">
  154. <el-form-item label="性别">
  155. <el-select v-model="form.gender" placeholder="请选择">
  156. <el-option v-for="dict in sys_user_sex" :key="dict.value" :label="dict.label" :value="parseInt(dict.value)" />
  157. </el-select>
  158. </el-form-item>
  159. </el-col>
  160. <el-col :span="24">
  161. <el-form-item label="所属站点" required>
  162. <el-cascader v-model="formAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }" placeholder="请选择站点"
  163. style="width: 100%" clearable @change="handleFormAreaChange" />
  164. </el-form-item>
  165. </el-col>
  166. <el-col :span="24"><div class="form-section-header">居住信息</div></el-col>
  167. <el-col :span="24">
  168. <el-form-item label="所在地区">
  169. <RegionCascader v-model="regionCascaderValue" />
  170. </el-form-item>
  171. </el-col>
  172. <el-col :span="24">
  173. <el-form-item label="详细住址" required><el-input v-model="form.address" placeholder="请输入街道/门牌号" /></el-form-item>
  174. </el-col>
  175. <el-col :span="12">
  176. <el-form-item label="房屋类型">
  177. <el-radio-group v-model="form.houseType">
  178. <el-radio v-for="dict in sys_house_type" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
  179. </el-radio-group>
  180. </el-form-item>
  181. </el-col>
  182. <el-col :span="12">
  183. <el-form-item label="入门方式" required>
  184. <el-radio-group v-model="form.entryMethod">
  185. <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
  186. </el-radio-group>
  187. </el-form-item>
  188. </el-col>
  189. <el-col :span="12" v-if="form.entryMethod === 'password'">
  190. <el-form-item label="开门密码" required>
  191. <el-input v-model="form.entryPassword" placeholder="请输入密码" />
  192. </el-form-item>
  193. </el-col>
  194. <el-col :span="12" v-if="form.entryMethod === 'key'">
  195. <el-form-item label="钥匙位置" required>
  196. <el-input v-model="form.keyLocation" placeholder="如:地毯下" />
  197. </el-form-item>
  198. </el-col>
  199. <el-col :span="24"><div class="form-section-header">其他</div></el-col>
  200. <el-col :span="24">
  201. <el-form-item label="用户标签">
  202. <el-select v-model="selectedTagIds" multiple placeholder="选择标签" style="width: 100%">
  203. <el-option v-for="tag in allUserTags" :key="tag.id" :label="tag.name" :value="tag.id">
  204. <el-tag :type="tag.colorType || 'info'" effect="light" size="small">{{ tag.name }}</el-tag>
  205. </el-option>
  206. </el-select>
  207. </el-form-item>
  208. </el-col>
  209. <el-col :span="24">
  210. <el-form-item label="备注说明"><el-input type="textarea" v-model="form.remark" rows="3" /></el-form-item>
  211. </el-col>
  212. </el-row>
  213. </el-form>
  214. <div style="text-align: center; margin-top: 20px;">
  215. <el-button @click="dialogVisible = false" size="large" style="width: 120px;">取消</el-button>
  216. <el-button type="primary" :loading="submitLoading" @click="saveUser" size="large" style="width: 120px;">保存</el-button>
  217. </div>
  218. </el-dialog>
  219. <!-- Remark Dialog -->
  220. <el-dialog v-model="remarkDialogVisible" title="添加备注" width="400px">
  221. <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入备注内容..." />
  222. <template #footer>
  223. <span class="dialog-footer">
  224. <el-button @click="remarkDialogVisible = false">取消</el-button>
  225. <el-button type="primary" @click="saveRemark">保存</el-button>
  226. </span>
  227. </template>
  228. </el-dialog>
  229. <!-- Pet Remark Dialog -->
  230. <el-dialog v-model="petRemarkDialogVisible" title="添加宠物备注" width="400px" append-to-body>
  231. <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入宠物备注内容..." />
  232. <template #footer>
  233. <span class="dialog-footer">
  234. <el-button @click="petRemarkDialogVisible = false">取消</el-button>
  235. <el-button type="primary" @click="savePetRemark">保存</el-button>
  236. </span>
  237. </template>
  238. </el-dialog>
  239. <!-- Full Add/Edit Pet Dialog -->
  240. <el-dialog v-model="petDialogVisible" :title="petForm.id ? '编辑宠物' : '新增宠物'" width="800px">
  241. <el-tabs v-model="petDialogActiveTab">
  242. <el-tab-pane label="基本信息" name="basic">
  243. <el-form :model="petForm" label-width="100px">
  244. <el-row>
  245. <el-col :span="24" style="display: flex; justify-content: center; margin-bottom: 20px;">
  246. <el-upload
  247. class="avatar-uploader"
  248. action="#"
  249. :show-file-list="false"
  250. :auto-upload="false"
  251. :on-change="handlePetUploadFile"
  252. >
  253. <el-avatar v-if="petAvatarDisplayUrl" :src="petAvatarDisplayUrl" :size="80" />
  254. <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
  255. </el-upload>
  256. </el-col>
  257. <el-col :span="12">
  258. <el-form-item label="宠物姓名" required><el-input v-model="petForm.name" /></el-form-item>
  259. </el-col>
  260. <el-col :span="12">
  261. <el-form-item label="性别">
  262. <el-select v-model="petForm.gender" placeholder="请选择">
  263. <el-option v-for="dict in sys_pet_gender" :key="dict.value" :label="dict.label" :value="parseInt(dict.value)" />
  264. </el-select>
  265. </el-form-item>
  266. </el-col>
  267. <el-col :span="12">
  268. <el-form-item label="品种" required>
  269. <el-select v-model="petForm.breed" placeholder="请选择品种" style="width: 100%" filterable allow-create default-first-option>
  270. <el-option v-for="dict in sys_pet_breed" :key="dict.value" :label="dict.label" :value="dict.value" />
  271. </el-select>
  272. </el-form-item>
  273. </el-col>
  274. <el-col :span="12">
  275. <el-form-item label="体型" required>
  276. <el-select v-model="petForm.size" style="width: 100%">
  277. <el-option v-for="dict in sys_pet_size" :key="dict.value" :label="dict.label" :value="dict.value" />
  278. </el-select>
  279. </el-form-item>
  280. </el-col>
  281. <el-col :span="12">
  282. <el-form-item label="体重(kg)" required><el-input-number v-model="petForm.weight" :min="0" :precision="1" style="width: 100%" /></el-form-item>
  283. </el-col>
  284. <el-col :span="12">
  285. <el-form-item label="年龄(岁)" required><el-input-number v-model="petForm.age" :min="0" style="width: 100%" /></el-form-item>
  286. </el-col>
  287. <el-col :span="24">
  288. <el-form-item label="性格关键词"><el-input v-model="petForm.personality" placeholder="如:活泼、粘人" /></el-form-item>
  289. </el-col>
  290. <el-col :span="24">
  291. <el-form-item label="萌宠性格"><el-input v-model="petForm.cutePersonality" type="textarea" placeholder="详细描述" /></el-form-item>
  292. </el-col>
  293. <el-col :span="24">
  294. <el-form-item label="宠物标签">
  295. <el-select v-model="petForm.tagIds" multiple placeholder="选择标签" style="width: 100%">
  296. <el-option v-for="tag in allPetTags" :key="tag.id" :label="tag.name" :value="tag.id">
  297. <el-tag :type="tag.colorType || 'info'" effect="light" size="small">{{ tag.name }}</el-tag>
  298. </el-option>
  299. </el-select>
  300. </el-form-item>
  301. </el-col>
  302. </el-row>
  303. </el-form>
  304. </el-tab-pane>
  305. <el-tab-pane label="家庭信息" name="family">
  306. <el-form :model="petForm" label-width="120px">
  307. <el-form-item label="新来家庭时间">
  308. <el-date-picker v-model="petForm.arrivalTime" type="date" placeholder="选择日期" style="width: 100%" />
  309. </el-form-item>
  310. <el-form-item label="家庭房屋类型" required>
  311. <el-radio-group v-model="petForm.houseType">
  312. <el-radio v-for="dict in sys_house_type" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
  313. </el-radio-group>
  314. </el-form-item>
  315. <el-form-item label="入门方式" required>
  316. <el-radio-group v-model="petForm.entryMethod">
  317. <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
  318. </el-radio-group>
  319. </el-form-item>
  320. <el-form-item label="密码" v-if="petForm.entryMethod === 'password'" required>
  321. <el-input v-model="petForm.entryPassword" placeholder="请输入门锁密码" />
  322. </el-form-item>
  323. <el-form-item label="钥匙位置" v-if="petForm.entryMethod === 'key'" required>
  324. <el-input v-model="petForm.keyLocation" placeholder="请输入钥匙存放位置" />
  325. </el-form-item>
  326. </el-form>
  327. </el-tab-pane>
  328. <el-tab-pane label="健康状况" name="health">
  329. <el-form :model="petForm" label-width="120px">
  330. <el-form-item label="健康状态" required>
  331. <el-radio-group v-model="petForm.healthStatus">
  332. <el-radio label="健康">健康</el-radio>
  333. <el-radio label="亚健康">亚健康</el-radio>
  334. <el-radio label="疾病">疾病</el-radio>
  335. </el-radio-group>
  336. </el-form-item>
  337. <el-form-item label="是否有攻击倾向" required>
  338. <el-switch v-model="petForm.aggression" active-text="是" inactive-text="否" :active-value="1" :inactive-value="0" />
  339. </el-form-item>
  340. <el-form-item label="疫苗情况" required>
  341. <el-radio-group v-model="petForm.vaccineStatus">
  342. <el-radio label="无">无</el-radio>
  343. <el-radio label="已打1次">已打1次</el-radio>
  344. <el-radio label="已打2次">已打2次</el-radio>
  345. <el-radio label="已打3次">已打3次</el-radio>
  346. </el-radio-group>
  347. </el-form-item>
  348. <el-form-item label="疫苗凭证">
  349. <el-upload class="avatar-uploader" action="#" :show-file-list="false" :auto-upload="false" :on-change="handlePetUploadVaccineCert">
  350. <img v-if="petVaccineCertDisplayUrl" :src="petVaccineCertDisplayUrl" class="avatar" style="width: 100px; height: 100px; object-fit: cover;" />
  351. <el-icon v-else class="avatar-uploader-icon" style="width: 100px; height: 100px; line-height: 100px;"><Plus /></el-icon>
  352. </el-upload>
  353. </el-form-item>
  354. <el-form-item label="既往病史" required>
  355. <el-input v-model="petForm.medicalHistory" type="textarea" placeholder="如有病史请记录" />
  356. </el-form-item>
  357. <el-form-item label="过敏史" required>
  358. <el-input v-model="petForm.allergies" type="textarea" placeholder="如有过敏源请记录" />
  359. </el-form-item>
  360. </el-form>
  361. </el-tab-pane>
  362. </el-tabs>
  363. <template #footer>
  364. <span class="dialog-footer">
  365. <el-button @click="petDialogVisible = false">取消</el-button>
  366. <el-button type="primary" :loading="submitLoading" @click="savePet">保存</el-button>
  367. </span>
  368. </template>
  369. </el-dialog>
  370. </div>
  371. </template>
  372. <script setup>
  373. import { ref, reactive, computed, onMounted, getCurrentInstance, toRefs } from 'vue'
  374. import { globalHeaders } from '@/utils/request'
  375. import { ElMessage, ElMessageBox } from 'element-plus'
  376. import { listCustomer, getCustomer, addCustomer, updateCustomer, delCustomer, changeCustomerStatus, updateCustomerRemark } from '@/api/archieves/customer'
  377. import { listAllTag } from '@/api/archieves/tag'
  378. import { listPetByUser, addPet, updatePet, delPet, updatePetRemark } from '@/api/archieves/pet'
  379. import { listAllChangeLog } from '@/api/archieves/changeLog'
  380. import { listAreaStation } from '@/api/system/areaStation'
  381. import { listOnStore as listBrandOnStore } from '@/api/system/tenant'
  382. import CustomerDetailDrawer from '@/components/CustomerDetailDrawer/index.vue'
  383. import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
  384. import { listAllService } from '@/api/service/list/index'
  385. import RegionCascader from '@/components/RegionCascader/index.vue'
  386. import { useRegionData } from '@/hooks/useRegionData'
  387. import PageSelect from '@/components/PageSelect/index.vue'
  388. import { useUserStore } from '@/store/modules/user'
  389. const userStore = useUserStore()
  390. const { codeToName, loadRegionData } = useRegionData()
  391. const { proxy } = getCurrentInstance()
  392. const { sys_user_sex, sys_customer_status, sys_house_type, sys_entry_method, sys_pet_gender, sys_pet_size, sys_pet_type, sys_pet_breed } = toRefs(
  393. proxy?.useDict('sys_user_sex', 'sys_customer_status', 'sys_house_type', 'sys_entry_method', 'sys_pet_gender', 'sys_pet_size', 'sys_pet_type', 'sys_pet_breed')
  394. )
  395. const loading = ref(false)
  396. const submitLoading = ref(false)
  397. const total = ref(0)
  398. const allNodes = ref([])
  399. // 废弃变量已清理,统一使用级联选择器展示全量树结构
  400. const loadAreaStation = () => {
  401. listAreaStation().then((res) => {
  402. allNodes.value = res.data || []
  403. })
  404. }
  405. const searchAreaValue = ref([])
  406. const onSearchAreaChange = (value) => {
  407. if (value && value.length > 0) {
  408. const lastId = value[value.length - 1];
  409. const node = allNodes.value.find(item => item.id === lastId);
  410. searchForm.stationId = lastId;
  411. searchForm.areaId = node ? node.parentId : undefined;
  412. } else {
  413. searchForm.areaId = undefined;
  414. searchForm.stationId = undefined;
  415. }
  416. handleSearch()
  417. }
  418. const queryParams = reactive({
  419. pageNum: 1,
  420. pageSize: 10,
  421. keyword: '',
  422. areaId: undefined,
  423. stationId: undefined,
  424. status: undefined
  425. })
  426. const searchForm = reactive({
  427. keyword: '',
  428. areaId: undefined,
  429. stationId: undefined
  430. })
  431. const dialogVisible = ref(false)
  432. const drawerVisible = ref(false)
  433. const petDrawerVisible = ref(false)
  434. const remarkDialogVisible = ref(false)
  435. const petRemarkDialogVisible = ref(false)
  436. const petDialogVisible = ref(false)
  437. const isEdit = ref(false)
  438. const detailActiveTab = ref('info')
  439. const petDialogActiveTab = ref('basic')
  440. const selectedTagIds = ref([])
  441. const currentUser = ref({})
  442. const currentPet = ref({})
  443. const currentPets = ref([])
  444. const tableData = ref([])
  445. const allUserTags = ref([])
  446. const allPetTags = ref([])
  447. const changeLogs = ref([])
  448. const userAvatarDisplayUrl = ref('')
  449. const petAvatarDisplayUrl = ref('')
  450. const petVaccineCertDisplayUrl = ref('')
  451. const brandList = ref([])
  452. const brandTotal = ref(0)
  453. const formAreaValue = ref([])
  454. const regionCascaderValue = ref([])
  455. const customerDetailRef = ref(null)
  456. const serviceList = ref([])
  457. const getServiceList = () => {
  458. listAllService().then((res) => {
  459. serviceList.value = res.data || []
  460. })
  461. }
  462. const form = reactive({
  463. id: undefined,
  464. name: '',
  465. phone: '',
  466. avatar: undefined,
  467. gender: undefined,
  468. birthday: '',
  469. idCard: '',
  470. areaId: undefined,
  471. stationId: undefined,
  472. regionCode: '',
  473. region: [],
  474. address: '',
  475. houseType: '',
  476. entryMethod: '',
  477. entryPassword: '',
  478. keyLocation: '',
  479. source: '',
  480. emergencyContact: '',
  481. emergencyPhone: '',
  482. memberLevel: 0,
  483. status: 0,
  484. remark: '',
  485. tagIds: [],
  486. tenantId: undefined
  487. })
  488. const petForm = reactive({
  489. id: undefined,
  490. userId: undefined,
  491. avatar: undefined,
  492. name: '',
  493. type: 0,
  494. gender: undefined,
  495. breed: '',
  496. birthday: '',
  497. age: 1,
  498. weight: 5,
  499. size: 'small',
  500. isSterilized: 0,
  501. arrivalTime: '',
  502. houseType: '',
  503. entryMethod: '',
  504. entryPassword: '',
  505. keyLocation: '',
  506. personality: '',
  507. cutePersonality: '',
  508. healthStatus: '',
  509. aggression: 0,
  510. vaccineStatus: '',
  511. vaccineCert: undefined,
  512. medicalHistory: '',
  513. allergies: '',
  514. remark: '',
  515. tagIds: []
  516. })
  517. const remarkForm = reactive({ content: '' })
  518. const areaTreeOptions = computed(() => {
  519. const buildTree = (data, parentId) => {
  520. return data
  521. .filter(item => String(item.parentId) === String(parentId))
  522. .map(item => {
  523. const children = buildTree(data, item.id)
  524. const node = {
  525. id: item.id,
  526. name: item.name,
  527. // 如果不是站点且没有下级了,则不可选(防止误选区域叶子点)
  528. disabled: Number(item.type) !== 2 && (!children || children.length === 0)
  529. }
  530. if (children.length > 0) node.children = children
  531. return node
  532. })
  533. }
  534. return buildTree(allNodes.value, 0)
  535. })
  536. const handleFormAreaChange = (value) => {
  537. if (value && value.length > 0) {
  538. const lastId = value[value.length - 1];
  539. const node = allNodes.value.find(item => item.id === lastId);
  540. form.stationId = lastId;
  541. form.areaId = node ? node.parentId : undefined;
  542. } else {
  543. form.stationId = undefined;
  544. form.areaId = undefined;
  545. }
  546. }
  547. const getBrandList = async (pageNum = 1) => {
  548. const res = await listBrandOnStore({ pageNum, pageSize: 10 })
  549. if (res.code === 200) {
  550. brandList.value = res.rows || []
  551. brandTotal.value = res.total || 0
  552. }
  553. }
  554. const handleBrandPageChange = (page) => {
  555. getBrandList(Number(page))
  556. }
  557. const handleBrandVisibleChange = (visible) => {
  558. if (visible) {
  559. getBrandList(1)
  560. }
  561. }
  562. const getList = () => {
  563. loading.value = true
  564. queryParams.keyword = searchForm.keyword
  565. queryParams.areaId = searchForm.areaId || undefined
  566. queryParams.stationId = searchForm.stationId || undefined
  567. listCustomer(queryParams).then((res) => {
  568. tableData.value = res.rows
  569. total.value = res.total
  570. }).finally(() => {
  571. loading.value = false
  572. })
  573. }
  574. const handleSearch = () => {
  575. queryParams.pageNum = 1
  576. getList()
  577. }
  578. const loadTags = () => {
  579. listAllTag({ category: 'customer', status: 0 }).then((res) => {
  580. allUserTags.value = res.data || []
  581. }).catch((err) => {
  582. console.error('加载用户标签失败', err)
  583. })
  584. listAllTag({ category: 'pet', status: 0 }).then((res) => {
  585. allPetTags.value = res.data || []
  586. }).catch((err) => {
  587. console.error('加载宠物标签失败', err)
  588. })
  589. }
  590. const handleAdd = () => {
  591. isEdit.value = false
  592. selectedTagIds.value = []
  593. Object.assign(form, {
  594. id: undefined, name: '', phone: '', avatar: undefined, gender: undefined, birthday: '', idCard: '',
  595. areaId: undefined, stationId: undefined, regionCode: '', region: [], address: '',
  596. houseType: '', entryMethod: '', entryPassword: '', keyLocation: '', source: '',
  597. emergencyContact: '', emergencyPhone: '', memberLevel: 0, status: 0, remark: '', tagIds: [],
  598. tenantId: undefined
  599. })
  600. userAvatarDisplayUrl.value = ''
  601. formAreaValue.value = []
  602. regionCascaderValue.value = []
  603. dialogVisible.value = true
  604. }
  605. const handleEdit = (row) => {
  606. isEdit.value = true
  607. getCustomer(row.id).then((res) => {
  608. const data = res.data
  609. Object.assign(form, {
  610. id: data.id, name: data.name, phone: data.phone, avatar: data.avatar, gender: data.gender,
  611. birthday: data.birthday, idCard: data.idCard, areaId: data.areaId, stationId: data.stationId,
  612. regionCode: data.regionCode, region: data.regionCode ? data.regionCode.split('/') : [],
  613. address: data.address, houseType: data.houseType, entryMethod: data.entryMethod,
  614. entryPassword: data.entryPassword, keyLocation: data.keyLocation, source: data.source,
  615. emergencyContact: data.emergencyContact, emergencyPhone: data.emergencyPhone,
  616. memberLevel: data.memberLevel, status: data.status, remark: data.remark, tagIds: [],
  617. tenantId: data.tenantId
  618. })
  619. userAvatarDisplayUrl.value = data.avatarUrl || ''
  620. // Restore area cascader value path
  621. if (data.stationId || data.areaId) {
  622. const targetId = data.stationId || data.areaId;
  623. const path = [];
  624. let currentId = targetId;
  625. while (currentId && String(currentId) !== '0') {
  626. path.unshift(currentId);
  627. const currentArea = allNodes.value.find((item) => String(item.id) === String(currentId));
  628. if (currentArea) {
  629. currentId = currentArea.parentId;
  630. } else {
  631. break;
  632. }
  633. }
  634. formAreaValue.value = path;
  635. } else {
  636. formAreaValue.value = []
  637. }
  638. // Restore region cascader value
  639. regionCascaderValue.value = data.regionCode ? data.regionCode.split('/') : []
  640. selectedTagIds.value = data.tags ? data.tags.map(t => t.id) : []
  641. dialogVisible.value = true
  642. })
  643. }
  644. const handleDetail = (row) => {
  645. getCustomer(row.id).then((res) => {
  646. const data = res.data
  647. // Convert areaId to area name
  648. if (data.areaId) {
  649. const area = allNodes.value.find(n => n.id === data.areaId)
  650. data.areaName = area ? area.name : '-'
  651. } else {
  652. data.areaName = '-'
  653. }
  654. // Convert stationId to station name
  655. if (data.stationId) {
  656. const station = allNodes.value.find(n => n.id === data.stationId)
  657. data.stationName = station ? station.name : '-'
  658. } else {
  659. data.stationName = '-'
  660. }
  661. currentUser.value = data
  662. detailActiveTab.value = 'info'
  663. drawerVisible.value = true
  664. })
  665. }
  666. const loadDetailPets = (userId) => {
  667. listPetByUser(userId).then((res) => {
  668. currentPets.value = res.data || []
  669. })
  670. }
  671. const loadDetailLogs = (targetId, targetType) => {
  672. listAllChangeLog(targetId, targetType).then((res) => {
  673. changeLogs.value = res.data || []
  674. })
  675. }
  676. const handleRemark = (row) => {
  677. currentUser.value = row
  678. remarkForm.content = ''
  679. remarkDialogVisible.value = true
  680. }
  681. const saveRemark = () => {
  682. if (!remarkForm.content) return ElMessage.warning('请输入内容')
  683. const data = {
  684. id: currentUser.value.id,
  685. content: remarkForm.content
  686. }
  687. updateCustomerRemark(data).then(() => {
  688. ElMessage.success('备注添加成功')
  689. remarkDialogVisible.value = false
  690. getList()
  691. // 刷新 drawer 中的变更日志
  692. if (drawerVisible.value) {
  693. loadDetailLogs(currentUser.value.id, 'customer')
  694. }
  695. })
  696. }
  697. const savePetRemark = () => {
  698. if (!remarkForm.content) return ElMessage.warning('请输入内容')
  699. const data = {
  700. petId: currentPet.value.id,
  701. content: remarkForm.content
  702. }
  703. updatePetRemark(data).then(() => {
  704. ElMessage.success('宠物备注添加成功')
  705. petRemarkDialogVisible.value = false
  706. if (drawerVisible.value) {
  707. loadDetailPets(currentUser.value.id)
  708. loadDetailLogs(currentUser.value.id, 'customer')
  709. }
  710. getList()
  711. })
  712. }
  713. const saveUser = () => {
  714. if (!form.tenantId) return ElMessage.warning('请选择所属品牌')
  715. if (!form.name) return ElMessage.warning('请输入姓名')
  716. if (!form.phone) return ElMessage.warning('请输入电话')
  717. if (!form.stationId) return ElMessage.warning('请选择所属站点')
  718. if (!form.address) return ElMessage.warning('请输入详细住址')
  719. if (!form.entryMethod) return ElMessage.warning('请选择入门方式')
  720. if (form.entryMethod === 'password' && !form.entryPassword) return ElMessage.warning('请输入开门密码')
  721. if (form.entryMethod === 'key' && !form.keyLocation) return ElMessage.warning('请输入钥匙存放位置')
  722. submitLoading.value = true
  723. form.tagIds = selectedTagIds.value
  724. if (regionCascaderValue.value && regionCascaderValue.value.length > 0) {
  725. form.regionCode = regionCascaderValue.value.join('/')
  726. } else {
  727. form.regionCode = ''
  728. }
  729. const api = isEdit.value ? updateCustomer(form) : addCustomer(form)
  730. api.then(() => {
  731. ElMessage.success('保存成功')
  732. dialogVisible.value = false
  733. getList()
  734. }).finally(() => {
  735. submitLoading.value = false
  736. })
  737. }
  738. const handleDelete = (row) => {
  739. ElMessageBox.confirm('确认删除该用户档案吗?', '提示', { type: 'warning' }).then(() => {
  740. delCustomer(row.id).then(() => {
  741. ElMessage.success('删除成功')
  742. getList()
  743. })
  744. })
  745. }
  746. const handleStatusChange = (row) => {
  747. const statusText = row.status === 0 ? '启用' : '停用'
  748. ElMessageBox.confirm(`确认${statusText}用户 "${row.name}" 吗?`, '提示', { type: 'warning' }).then(() => {
  749. changeCustomerStatus(row.id, row.status).then(() => {
  750. ElMessage.success(`${statusText}成功`)
  751. })
  752. }).catch(() => {
  753. row.status = row.status === 0 ? 1 : 0
  754. })
  755. }
  756. const handleCommand = (command, row) => {
  757. if (command === 'remark') {
  758. handleRemark(row)
  759. } else if (command === 'delete') {
  760. handleDelete(row)
  761. }
  762. }
  763. const baseUrl = import.meta.env.VITE_APP_BASE_API
  764. const uploadUrl = baseUrl + '/resource/oss/upload'
  765. const handleUserUploadFile = async (file) => {
  766. const formData = new FormData()
  767. formData.append('file', file.raw)
  768. try {
  769. const headers = globalHeaders()
  770. const res = await fetch(uploadUrl, {
  771. method: 'POST',
  772. headers: {
  773. 'Authorization': headers.Authorization,
  774. 'clientid': headers.clientid
  775. },
  776. body: formData
  777. })
  778. const result = await res.json()
  779. if (result.code === 200) {
  780. form.avatar = result.data.ossId
  781. userAvatarDisplayUrl.value = result.data.url
  782. } else {
  783. ElMessage.error(result.msg || '头像上传失败')
  784. }
  785. } catch (e) {
  786. ElMessage.error('头像上传失败')
  787. }
  788. }
  789. const handlePetUploadFile = async (file) => {
  790. const formData = new FormData()
  791. formData.append('file', file.raw)
  792. try {
  793. const headers = globalHeaders()
  794. const res = await fetch(uploadUrl, {
  795. method: 'POST',
  796. headers: {
  797. 'Authorization': headers.Authorization,
  798. 'clientid': headers.clientid
  799. },
  800. body: formData
  801. })
  802. const result = await res.json()
  803. if (result.code === 200) {
  804. petForm.avatar = result.data.ossId
  805. petAvatarDisplayUrl.value = result.data.url
  806. } else {
  807. ElMessage.error(result.msg || '头像上传失败')
  808. }
  809. } catch (e) {
  810. ElMessage.error('头像上传失败')
  811. }
  812. }
  813. const handlePetUploadVaccineCert = async (file) => {
  814. const formData = new FormData()
  815. formData.append('file', file.raw)
  816. try {
  817. const headers = globalHeaders()
  818. const res = await fetch(uploadUrl, {
  819. method: 'POST',
  820. headers: {
  821. 'Authorization': headers.Authorization,
  822. 'clientid': headers.clientid
  823. },
  824. body: formData
  825. })
  826. const result = await res.json()
  827. if (result.code === 200) {
  828. petForm.vaccineCert = result.data.ossId
  829. petVaccineCertDisplayUrl.value = result.data.url
  830. } else {
  831. ElMessage.error(result.msg || '疫苗凭证上传失败')
  832. }
  833. } catch (e) {
  834. ElMessage.error('疫苗凭证上传失败')
  835. }
  836. }
  837. const openAddPet = () => {
  838. petDialogActiveTab.value = 'basic'
  839. Object.assign(petForm, {
  840. id: undefined, userId: currentUser.value.id, avatar: undefined, name: '', type: 0, gender: undefined,
  841. breed: '', birthday: '', age: 1, weight: 5, size: 'small', isSterilized: 0,
  842. arrivalTime: '', houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
  843. personality: '', cutePersonality: '', healthStatus: '健康', aggression: 0,
  844. vaccineStatus: '无', vaccineCert: undefined, medicalHistory: '', allergies: '', remark: '', tagIds: []
  845. })
  846. petAvatarDisplayUrl.value = ''
  847. petVaccineCertDisplayUrl.value = ''
  848. petDialogVisible.value = true
  849. }
  850. const handlePetDetail = (row) => {
  851. currentPet.value = row
  852. petDrawerVisible.value = true
  853. }
  854. const handlePetEdit = (row) => {
  855. petDialogActiveTab.value = 'basic'
  856. Object.assign(petForm, {
  857. id: row.id, userId: row.userId, avatar: row.avatar, name: row.name, type: row.type,
  858. gender: row.gender, breed: row.breed, birthday: row.birthday, age: row.age,
  859. weight: row.weight, size: row.size, isSterilized: row.isSterilized,
  860. arrivalTime: row.arrivalTime, houseType: row.houseType, entryMethod: row.entryMethod,
  861. entryPassword: row.entryPassword, keyLocation: row.keyLocation,
  862. personality: row.personality, cutePersonality: row.cutePersonality,
  863. healthStatus: row.healthStatus, aggression: row.aggression,
  864. vaccineStatus: row.vaccineStatus, vaccineCert: row.vaccineCert,
  865. medicalHistory: row.medicalHistory, allergies: row.allergies, remark: row.remark,
  866. tagIds: row.tags ? row.tags.map(t => t.id) : []
  867. })
  868. petAvatarDisplayUrl.value = row.avatarUrl || ''
  869. petVaccineCertDisplayUrl.value = row.vaccineCertUrl || ''
  870. petDialogVisible.value = true
  871. }
  872. const handlePetRemark = (row) => {
  873. currentPet.value = row
  874. remarkForm.content = ''
  875. petRemarkDialogVisible.value = true
  876. }
  877. const handlePetDelete = (row) => {
  878. ElMessageBox.confirm(`确认删除宠物 [${row.name}] 吗?`, '提示', { type: 'warning' }).then(() => {
  879. delPet(row.id).then(() => {
  880. ElMessage.success('宠物删除成功')
  881. loadDetailPets(currentUser.value.id)
  882. getList()
  883. })
  884. })
  885. }
  886. const savePet = () => {
  887. if (!petForm.name) return ElMessage.warning('请输入宠物姓名');
  888. if (!petForm.userId) return ElMessage.warning('请选择所属主人');
  889. if (!petForm.breed) return ElMessage.warning('请选择品种');
  890. if (!petForm.size) return ElMessage.warning('请选择体型');
  891. if (petForm.weight === undefined || petForm.weight === null) return ElMessage.warning('请输入体重(kg)');
  892. if (petForm.age === undefined || petForm.age === null) return ElMessage.warning('请输入年龄(岁)');
  893. if (!petForm.houseType) return ElMessage.warning('请选择家庭房屋类型');
  894. if (!petForm.entryMethod) return ElMessage.warning('请选择入门方式');
  895. if (petForm.entryMethod === 'password' && !petForm.entryPassword) return ElMessage.warning('请输入门锁密码');
  896. if (petForm.entryMethod === 'key' && !petForm.keyLocation) return ElMessage.warning('请输入钥匙存放位置');
  897. if (!petForm.healthStatus) return ElMessage.warning('请选择健康状态');
  898. if (petForm.aggression === undefined || petForm.aggression === null) return ElMessage.warning('请选择是否有攻击倾向');
  899. if (!petForm.vaccineStatus) return ElMessage.warning('请选择疫苗情况');
  900. if (!petForm.medicalHistory) return ElMessage.warning('请输入既往病史');
  901. if (!petForm.allergies) return ElMessage.warning('请输入过敏史');
  902. submitLoading.value = true;
  903. const data = { ...petForm, aggression: Number(petForm.aggression) || 0 };
  904. const api = data.id ? updatePet(data) : addPet(data);
  905. api.then(() => {
  906. ElMessage.success('宠物档案保存成功');
  907. petDialogVisible.value = false;
  908. customerDetailRef.value?.refresh();
  909. getList();
  910. }).finally(() => {
  911. submitLoading.value = false;
  912. });
  913. };
  914. onMounted(() => {
  915. getList()
  916. loadTags()
  917. loadAreaStation()
  918. loadRegionData()
  919. getBrandList()
  920. getServiceList()
  921. })
  922. </script>
  923. <style scoped>
  924. .page-container { padding: 20px; }
  925. .card-header { display: flex; justify-content: space-between; align-items: center; }
  926. .title { font-weight: bold; }
  927. .profile-header {
  928. display: flex;
  929. align-items: center;
  930. margin-bottom: 20px;
  931. padding-bottom: 20px;
  932. border-bottom: 1px solid #f0f0f0;
  933. }
  934. .profile-basic {
  935. margin-left: 20px;
  936. }
  937. .name-row {
  938. display: flex;
  939. align-items: center;
  940. }
  941. .name {
  942. font-size: 20px;
  943. font-weight: bold;
  944. color: #303133;
  945. }
  946. .phone {
  947. margin-left: 10px;
  948. color: #666;
  949. }
  950. .section-title {
  951. font-size: 16px;
  952. font-weight: bold;
  953. margin-bottom: 15px;
  954. border-left: 4px solid #409EFF;
  955. padding-left: 10px;
  956. line-height: 1.2;
  957. }
  958. .form-section-header {
  959. font-weight: bold;
  960. margin-bottom: 15px;
  961. margin-top: 10px;
  962. padding-bottom: 5px;
  963. border-bottom: 1px dashed #eee;
  964. color: #303133;
  965. }
  966. .upload-avatar:hover {
  967. cursor: pointer;
  968. opacity: 0.8;
  969. }
  970. .pagination-container {
  971. margin-top: 20px;
  972. display: flex;
  973. justify-content: flex-end;
  974. }
  975. /* Add Upload Styles */
  976. .avatar-uploader .el-upload {
  977. cursor: pointer;
  978. position: relative;
  979. overflow: hidden;
  980. display: inline-block;
  981. }
  982. .avatar-uploader-icon {
  983. font-size: 28px;
  984. color: #8c939d;
  985. width: 100px;
  986. height: 100px;
  987. text-align: center;
  988. border: 1px dashed #dcdfe6;
  989. border-radius: 4px;
  990. display: flex;
  991. justify-content: center;
  992. align-items: center;
  993. transition: .2s;
  994. }
  995. .avatar-uploader-icon:hover {
  996. border-color: #409EFF;
  997. color: #409EFF;
  998. }
  999. .avatar {
  1000. width: 100px;
  1001. height: 100px;
  1002. display: block;
  1003. border-radius: 4px;
  1004. }
  1005. </style>