index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  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="searchAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }"
  9. placeholder="所属站点" style="width: 240px; margin-right: 10px" clearable @change="onSearchAreaChange" />
  10. <el-input v-model="searchForm.keyword" placeholder="搜索姓名/手机号" style="width: 200px; margin-right: 10px;"
  11. clearable @keyup.enter="handleSearch" @clear="handleSearch" />
  12. <el-button type="primary" icon="Plus" @click="handleAdd"
  13. v-hasPermi="['archieves:customer:add']">新增用户</el-button>
  14. </div>
  15. </div>
  16. </template>
  17. <el-table :data="tableData" v-loading="loading" style="width: 100%"
  18. :header-cell-style="{ background: '#f5f7fa' }">
  19. <el-table-column label="用户基本信息" width="250">
  20. <template #default="scope">
  21. <div style="display: flex; align-items: center;">
  22. <el-avatar :size="40" :src="scope.row.avatarUrl" style="margin-right: 10px;" />
  23. <div>
  24. <div style="font-weight: bold;">{{ scope.row.name }}
  25. <dict-tag :options="sys_user_sex" :value="scope.row.gender" />
  26. </div>
  27. <div style="font-size: 12px; color: #999;">{{ scope.row.phone }}</div>
  28. </div>
  29. </div>
  30. </template>
  31. </el-table-column>
  32. <el-table-column label="住址" show-overflow-tooltip min-width="150">
  33. <template #default="scope">
  34. {{[scope.row.regionCode ? scope.row.regionCode.split('/').map(c => codeToName(c)).filter(Boolean).join(' ')
  35. : '', 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"
  41. size="small" style="margin-right: 5px;">{{ tag.name }}</el-tag>
  42. </template>
  43. </el-table-column>
  44. <el-table-column label="录入信息" width="200">
  45. <template #default="scope">
  46. <div><el-tag size="small" effect="plain" :type="scope.row.tenantName ? '' : 'warning'">{{
  47. scope.row.tenantName || '-' }}</el-tag></div>
  48. <div style="font-size: 12px; color: #999; margin-top: 4px;">{{ scope.row.createTime }}</div>
  49. </template>
  50. </el-table-column>
  51. <el-table-column label="订单数量" width="120" align="center" sortable prop="orderCount">
  52. <template #default="scope">
  53. <div>{{ scope.row.orderCount }}单</div>
  54. </template>
  55. </el-table-column>
  56. <el-table-column prop="petCount" label="关联宠物" width="100" align="center">
  57. <template #default="scope">
  58. <el-tag size="small" round>{{ scope.row.petCount }}只</el-tag>
  59. </template>
  60. </el-table-column>
  61. <el-table-column label="状态" width="100" align="center">
  62. <template #default="scope">
  63. <el-switch v-model="scope.row.status" :active-value="0" :inactive-value="1" inline-prompt active-text="正常"
  64. inactive-text="停用" @change="handleStatusChange(scope.row)" />
  65. </template>
  66. </el-table-column>
  67. <el-table-column prop="remark" label="备注" show-overflow-tooltip min-width="150" />
  68. <el-table-column label="操作" width="200" align="center">
  69. <template #default="scope">
  70. <el-button link type="primary" size="small" @click="handleDetail(scope.row)"
  71. v-hasPermi="['archieves:customer:query']">详情</el-button>
  72. <el-button link type="primary" size="small" @click="handleEdit(scope.row)"
  73. v-hasPermi="['archieves:customer:edit']">编辑</el-button>
  74. <el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, scope.row)"
  75. style="margin-left: 10px; vertical-align: middle">
  76. <el-button link type="primary" size="small">
  77. 更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
  78. </el-button>
  79. <template #dropdown>
  80. <el-dropdown-menu>
  81. <el-dropdown-item command="remark" v-hasPermi="['archieves:customer:remark']">添加备注</el-dropdown-item>
  82. <el-dropdown-item command="delete" style="color: #F56C6C"
  83. v-hasPermi="['archieves:customer:remove']">删除用户</el-dropdown-item>
  84. </el-dropdown-menu>
  85. </template>
  86. </el-dropdown>
  87. </template>
  88. </el-table-column>
  89. </el-table>
  90. <div class="pagination-container">
  91. <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize"
  92. :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" :total="total"
  93. @size-change="getList" @current-change="getList" />
  94. </div>
  95. </el-card>
  96. <!-- User Detail Drawer -->
  97. <CustomerDetailDrawer ref="customerDetailRef" v-model:visible="drawerVisible" :customer-id="currentUser.id" editable
  98. :service-list="serviceList" :area-station-list="allNodes" @add-pet="openAddPet" @pet-detail="handlePetDetail"
  99. @pet-edit="handlePetEdit" @pet-remark="handlePetRemark" @pet-delete="handlePetDelete" />
  100. <!-- Pet Detail Drawer -->
  101. <PetDetailDrawer v-model:visible="petDrawerVisible" :pet-id="currentPet.id" :service-list="serviceList"
  102. @remark-saved="getList" />
  103. <!-- Add/Edit User Dialog -->
  104. <AddCustomerDialog v-model:visible="customerDialogVisible" :customer-data="customerEditData" :show-brand="true"
  105. @success="onCustomerSaved" />
  106. <!-- Remark Dialog -->
  107. <el-dialog v-model="remarkDialogVisible" title="添加备注" width="400px">
  108. <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入备注内容..." />
  109. <template #footer>
  110. <span class="dialog-footer">
  111. <el-button @click="remarkDialogVisible = false">取消</el-button>
  112. <el-button type="primary" @click="saveRemark">保存</el-button>
  113. </span>
  114. </template>
  115. </el-dialog>
  116. <!-- Pet Remark Dialog -->
  117. <el-dialog v-model="petRemarkDialogVisible" title="添加宠物备注" width="400px" append-to-body>
  118. <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入宠物备注内容..." />
  119. <template #footer>
  120. <span class="dialog-footer">
  121. <el-button @click="petRemarkDialogVisible = false">取消</el-button>
  122. <el-button type="primary" @click="savePetRemark">保存</el-button>
  123. </span>
  124. </template>
  125. </el-dialog>
  126. <AddPetDialog v-model:visible="petDialogVisible" :user-id="currentUser.id"
  127. :user-options="[{ id: currentUser.id, name: currentUser.name, phone: currentUser.phone }]" :locked-owner="true"
  128. :pet-data="petEditData" @success="onPetSaved" />
  129. </div>
  130. </template>
  131. <script setup>
  132. import { ref, reactive, computed, onMounted, getCurrentInstance, toRefs } from 'vue'
  133. import { ElMessage, ElMessageBox } from 'element-plus'
  134. import { listCustomer, getCustomer, delCustomer, changeCustomerStatus, updateCustomerRemark } from '@/api/archieves/customer'
  135. import { listPetByUser, delPet, updatePetRemark } from '@/api/archieves/pet'
  136. import { listAllChangeLog } from '@/api/archieves/changeLog'
  137. import { listAreaStation } from '@/api/system/areaStation'
  138. import CustomerDetailDrawer from '@/components/CustomerDetailDrawer/index.vue'
  139. import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
  140. import AddCustomerDialog from '@/components/AddCustomerDialog/index.vue'
  141. import AddPetDialog from '@/components/AddPetDialog/index.vue'
  142. import { listAllService } from '@/api/service/list/index'
  143. import { useRegionData } from '@/hooks/useRegionData'
  144. const { codeToName, loadRegionData } = useRegionData()
  145. const { proxy } = getCurrentInstance()
  146. const { sys_user_sex, sys_customer_status } = toRefs(
  147. proxy?.useDict('sys_user_sex', 'sys_customer_status')
  148. )
  149. const loading = ref(false)
  150. const total = ref(0)
  151. const allNodes = ref([])
  152. const loadAreaStation = () => {
  153. listAreaStation().then((res) => {
  154. allNodes.value = res.data || []
  155. })
  156. }
  157. const searchAreaValue = ref([])
  158. const onSearchAreaChange = (value) => {
  159. if (value && value.length > 0) {
  160. const lastId = value[value.length - 1];
  161. const node = allNodes.value.find(item => item.id === lastId);
  162. searchForm.stationId = lastId;
  163. searchForm.areaId = node ? node.parentId : undefined;
  164. } else {
  165. searchForm.areaId = undefined;
  166. searchForm.stationId = undefined;
  167. }
  168. handleSearch()
  169. }
  170. const queryParams = reactive({
  171. pageNum: 1,
  172. pageSize: 10,
  173. keyword: '',
  174. areaId: undefined,
  175. stationId: undefined,
  176. status: undefined
  177. })
  178. const searchForm = reactive({
  179. keyword: '',
  180. areaId: undefined,
  181. stationId: undefined
  182. })
  183. const customerDialogVisible = ref(false)
  184. const drawerVisible = ref(false)
  185. const petDrawerVisible = ref(false)
  186. const remarkDialogVisible = ref(false)
  187. const petRemarkDialogVisible = ref(false)
  188. const petDialogVisible = ref(false)
  189. const detailActiveTab = ref('info')
  190. const customerEditData = ref(null)
  191. const petEditData = ref(null)
  192. const currentUser = ref({})
  193. const currentPet = ref({})
  194. const currentPets = ref([])
  195. const tableData = ref([])
  196. const changeLogs = ref([])
  197. const customerDetailRef = ref(null)
  198. const serviceList = ref([])
  199. const getServiceList = () => {
  200. listAllService().then((res) => {
  201. serviceList.value = res.data || []
  202. })
  203. }
  204. const remarkForm = reactive({ content: '' })
  205. const areaTreeOptions = computed(() => {
  206. const buildTree = (data, parentId) => {
  207. return data
  208. .filter(item => String(item.parentId) === String(parentId))
  209. .map(item => {
  210. const children = buildTree(data, item.id)
  211. const node = {
  212. id: item.id,
  213. name: item.name,
  214. // 如果不是站点且没有下级了,则不可选(防止误选区域叶子点)
  215. disabled: Number(item.type) !== 2 && (!children || children.length === 0)
  216. }
  217. if (children.length > 0) node.children = children
  218. return node
  219. })
  220. }
  221. return buildTree(allNodes.value, 0)
  222. })
  223. const getList = () => {
  224. loading.value = true
  225. queryParams.keyword = searchForm.keyword
  226. queryParams.areaId = searchForm.areaId || undefined
  227. queryParams.stationId = searchForm.stationId || undefined
  228. listCustomer(queryParams).then((res) => {
  229. tableData.value = res.rows
  230. total.value = res.total
  231. }).finally(() => {
  232. loading.value = false
  233. })
  234. }
  235. const handleSearch = () => {
  236. queryParams.pageNum = 1
  237. getList()
  238. }
  239. const handleAdd = () => {
  240. customerEditData.value = null
  241. customerDialogVisible.value = true
  242. }
  243. const handleEdit = (row) => {
  244. getCustomer(row.id).then((res) => {
  245. customerEditData.value = res.data
  246. customerDialogVisible.value = true
  247. })
  248. }
  249. const onCustomerSaved = () => {
  250. customerDialogVisible.value = false
  251. getList()
  252. if (drawerVisible.value) {
  253. loadDetailLogs(currentUser.value.id, 'customer')
  254. }
  255. }
  256. const openAddPet = () => {
  257. petEditData.value = null
  258. petDialogVisible.value = true
  259. }
  260. const handlePetEdit = (row) => {
  261. petEditData.value = row
  262. petDialogVisible.value = true
  263. }
  264. const onPetSaved = () => {
  265. petDialogVisible.value = false
  266. customerDetailRef.value?.refresh()
  267. getList()
  268. }
  269. const handleDetail = (row) => {
  270. getCustomer(row.id).then((res) => {
  271. const data = res.data
  272. // Convert areaId to area name
  273. if (data.areaId) {
  274. const area = allNodes.value.find(n => n.id === data.areaId)
  275. data.areaName = area ? area.name : '-'
  276. } else {
  277. data.areaName = '-'
  278. }
  279. // Convert stationId to station name
  280. if (data.stationId) {
  281. const station = allNodes.value.find(n => n.id === data.stationId)
  282. data.stationName = station ? station.name : '-'
  283. } else {
  284. data.stationName = '-'
  285. }
  286. currentUser.value = data
  287. detailActiveTab.value = 'info'
  288. drawerVisible.value = true
  289. })
  290. }
  291. const loadDetailPets = (userId) => {
  292. listPetByUser(userId).then((res) => {
  293. currentPets.value = res.data || []
  294. })
  295. }
  296. const loadDetailLogs = (targetId, targetType) => {
  297. listAllChangeLog(targetId, targetType).then((res) => {
  298. changeLogs.value = res.data || []
  299. })
  300. }
  301. const handleRemark = (row) => {
  302. currentUser.value = row
  303. remarkForm.content = ''
  304. remarkDialogVisible.value = true
  305. }
  306. const saveRemark = () => {
  307. if (!remarkForm.content) return ElMessage.warning('请输入内容')
  308. const data = {
  309. id: currentUser.value.id,
  310. content: remarkForm.content
  311. }
  312. updateCustomerRemark(data).then(() => {
  313. ElMessage.success('备注添加成功')
  314. remarkDialogVisible.value = false
  315. getList()
  316. // 刷新 drawer 中的变更日志
  317. if (drawerVisible.value) {
  318. loadDetailLogs(currentUser.value.id, 'customer')
  319. }
  320. })
  321. }
  322. const savePetRemark = () => {
  323. if (!remarkForm.content) return ElMessage.warning('请输入内容')
  324. const data = {
  325. petId: currentPet.value.id,
  326. content: remarkForm.content
  327. }
  328. updatePetRemark(data).then(() => {
  329. ElMessage.success('宠物备注添加成功')
  330. petRemarkDialogVisible.value = false
  331. if (drawerVisible.value) {
  332. loadDetailPets(currentUser.value.id)
  333. loadDetailLogs(currentUser.value.id, 'customer')
  334. }
  335. getList()
  336. })
  337. }
  338. const handleDelete = (row) => {
  339. ElMessageBox.confirm('确认删除该用户档案吗?', '提示', { type: 'warning' }).then(() => {
  340. delCustomer(row.id).then(() => {
  341. ElMessage.success('删除成功')
  342. getList()
  343. })
  344. })
  345. }
  346. const handleStatusChange = (row) => {
  347. const statusText = row.status === 0 ? '启用' : '停用'
  348. ElMessageBox.confirm(`确认${statusText}用户 "${row.name}" 吗?`, '提示', { type: 'warning' }).then(() => {
  349. changeCustomerStatus(row.id, row.status).then(() => {
  350. ElMessage.success(`${statusText}成功`)
  351. })
  352. }).catch(() => {
  353. row.status = row.status === 0 ? 1 : 0
  354. })
  355. }
  356. const handleCommand = (command, row) => {
  357. if (command === 'remark') {
  358. handleRemark(row)
  359. } else if (command === 'delete') {
  360. handleDelete(row)
  361. }
  362. }
  363. const handlePetDetail = (row) => {
  364. currentPet.value = row
  365. petDrawerVisible.value = true
  366. }
  367. const handlePetRemark = (row) => {
  368. currentPet.value = row
  369. remarkForm.content = ''
  370. petRemarkDialogVisible.value = true
  371. }
  372. const handlePetDelete = (row) => {
  373. ElMessageBox.confirm(`确认删除宠物 [${row.name}] 吗?`, '提示', { type: 'warning' }).then(() => {
  374. delPet(row.id).then(() => {
  375. ElMessage.success('宠物删除成功')
  376. loadDetailPets(currentUser.value.id)
  377. getList()
  378. })
  379. })
  380. }
  381. onMounted(() => {
  382. getList()
  383. loadAreaStation()
  384. loadRegionData()
  385. getServiceList()
  386. })
  387. </script>
  388. <style scoped>
  389. .page-container {
  390. padding: 20px;
  391. }
  392. .card-header {
  393. display: flex;
  394. justify-content: space-between;
  395. align-items: center;
  396. }
  397. .title {
  398. font-weight: bold;
  399. }
  400. .profile-header {
  401. display: flex;
  402. align-items: center;
  403. margin-bottom: 20px;
  404. padding-bottom: 20px;
  405. border-bottom: 1px solid #f0f0f0;
  406. }
  407. .profile-basic {
  408. margin-left: 20px;
  409. }
  410. .name-row {
  411. display: flex;
  412. align-items: center;
  413. }
  414. .name {
  415. font-size: 20px;
  416. font-weight: bold;
  417. color: #303133;
  418. }
  419. .phone {
  420. margin-left: 10px;
  421. color: #666;
  422. }
  423. .section-title {
  424. font-size: 16px;
  425. font-weight: bold;
  426. margin-bottom: 15px;
  427. border-left: 4px solid #409EFF;
  428. padding-left: 10px;
  429. line-height: 1.2;
  430. }
  431. .form-section-header {
  432. font-weight: bold;
  433. margin-bottom: 15px;
  434. margin-top: 10px;
  435. padding-bottom: 5px;
  436. border-bottom: 1px dashed #eee;
  437. color: #303133;
  438. }
  439. .upload-avatar:hover {
  440. cursor: pointer;
  441. opacity: 0.8;
  442. }
  443. .pagination-container {
  444. margin-top: 20px;
  445. display: flex;
  446. justify-content: flex-end;
  447. }
  448. /* Add Upload Styles */
  449. .avatar-uploader .el-upload {
  450. cursor: pointer;
  451. position: relative;
  452. overflow: hidden;
  453. display: inline-block;
  454. }
  455. .avatar-uploader-icon {
  456. font-size: 28px;
  457. color: #8c939d;
  458. width: 100px;
  459. height: 100px;
  460. text-align: center;
  461. border: 1px dashed #dcdfe6;
  462. border-radius: 4px;
  463. display: flex;
  464. justify-content: center;
  465. align-items: center;
  466. transition: .2s;
  467. }
  468. .avatar-uploader-icon:hover {
  469. border-color: #409EFF;
  470. color: #409EFF;
  471. }
  472. .avatar {
  473. width: 100px;
  474. height: 100px;
  475. display: block;
  476. border-radius: 4px;
  477. }
  478. </style>