index.vue 39 KB

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