index.vue 36 KB

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