| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538 |
- <template>
- <div class="page-container">
- <el-card shadow="never">
- <template #header>
- <div class="card-header">
- <span class="title">用户管理</span>
- <div class="header-actions">
- <el-cascader v-model="searchAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }"
- placeholder="所属站点" style="width: 240px; margin-right: 10px" clearable @change="onSearchAreaChange" />
- <el-input v-model="searchForm.keyword" placeholder="搜索姓名/手机号" style="width: 200px; margin-right: 10px;"
- clearable @keyup.enter="handleSearch" @clear="handleSearch" />
- <el-button type="primary" icon="Plus" @click="handleAdd"
- v-hasPermi="['archieves:customer:add']">新增用户</el-button>
- </div>
- </div>
- </template>
- <el-table :data="tableData" v-loading="loading" style="width: 100%"
- :header-cell-style="{ background: '#f5f7fa' }">
- <el-table-column label="用户基本信息" width="250">
- <template #default="scope">
- <div style="display: flex; align-items: center;">
- <el-avatar :size="40" :src="scope.row.avatarUrl" style="margin-right: 10px;" />
- <div>
- <div style="font-weight: bold;">{{ scope.row.name }}
- <dict-tag :options="sys_user_sex" :value="scope.row.gender" />
- </div>
- <div style="font-size: 12px; color: #999;">{{ scope.row.phone }}</div>
- </div>
- </div>
- </template>
- </el-table-column>
- <el-table-column label="住址" show-overflow-tooltip min-width="150">
- <template #default="scope">
- {{[scope.row.regionCode ? scope.row.regionCode.split('/').map(c => codeToName(c)).filter(Boolean).join(' ')
- : '', scope.row.address].filter(Boolean).join(' ') || '-'}}
- </template>
- </el-table-column>
- <el-table-column label="用户标签" width="200">
- <template #default="scope">
- <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>
- </template>
- </el-table-column>
- <el-table-column label="录入信息" width="200">
- <template #default="scope">
- <div><el-tag size="small" effect="plain" :type="scope.row.tenantName ? '' : 'warning'">{{
- scope.row.tenantName || '-' }}</el-tag></div>
- <div style="font-size: 12px; color: #999; margin-top: 4px;">{{ scope.row.createTime }}</div>
- </template>
- </el-table-column>
- <el-table-column label="订单数量" width="120" align="center" sortable prop="orderCount">
- <template #default="scope">
- <div>{{ scope.row.orderCount }}单</div>
- </template>
- </el-table-column>
- <el-table-column prop="petCount" label="关联宠物" width="100" align="center">
- <template #default="scope">
- <el-tag size="small" round>{{ scope.row.petCount }}只</el-tag>
- </template>
- </el-table-column>
- <el-table-column label="状态" width="100" align="center">
- <template #default="scope">
- <el-switch v-model="scope.row.status" :active-value="0" :inactive-value="1" inline-prompt active-text="正常"
- inactive-text="停用" @change="handleStatusChange(scope.row)" />
- </template>
- </el-table-column>
- <el-table-column prop="remark" label="备注" show-overflow-tooltip min-width="150" />
- <el-table-column label="操作" width="200" align="center">
- <template #default="scope">
- <el-button link type="primary" size="small" @click="handleDetail(scope.row)"
- v-hasPermi="['archieves:customer:query']">详情</el-button>
- <el-button link type="primary" size="small" @click="handleEdit(scope.row)"
- v-hasPermi="['archieves:customer:edit']">编辑</el-button>
- <el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, scope.row)"
- style="margin-left: 10px; vertical-align: middle">
- <el-button link type="primary" size="small">
- 更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
- </el-button>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item command="remark" v-hasPermi="['archieves:customer:remark']">添加备注</el-dropdown-item>
- <el-dropdown-item command="delete" style="color: #F56C6C"
- v-hasPermi="['archieves:customer:remove']">删除用户</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- </template>
- </el-table-column>
- </el-table>
- <div class="pagination-container">
- <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize"
- :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" :total="total"
- @size-change="getList" @current-change="getList" />
- </div>
- </el-card>
- <!-- User Detail Drawer -->
- <CustomerDetailDrawer ref="customerDetailRef" v-model:visible="drawerVisible" :customer-id="currentUser.id" editable
- :service-list="serviceList" :area-station-list="allNodes" @add-pet="openAddPet" @pet-detail="handlePetDetail"
- @pet-edit="handlePetEdit" @pet-remark="handlePetRemark" @pet-delete="handlePetDelete" />
- <!-- Pet Detail Drawer -->
- <PetDetailDrawer v-model:visible="petDrawerVisible" :pet-id="currentPet.id" :service-list="serviceList"
- @remark-saved="getList" />
- <!-- Add/Edit User Dialog -->
- <AddCustomerDialog v-model:visible="customerDialogVisible" :customer-data="customerEditData" :show-brand="true"
- @success="onCustomerSaved" />
- <!-- Remark Dialog -->
- <el-dialog v-model="remarkDialogVisible" title="添加备注" width="400px">
- <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入备注内容..." />
- <template #footer>
- <span class="dialog-footer">
- <el-button @click="remarkDialogVisible = false">取消</el-button>
- <el-button type="primary" @click="saveRemark">保存</el-button>
- </span>
- </template>
- </el-dialog>
- <!-- Pet Remark Dialog -->
- <el-dialog v-model="petRemarkDialogVisible" title="添加宠物备注" width="400px" append-to-body>
- <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入宠物备注内容..." />
- <template #footer>
- <span class="dialog-footer">
- <el-button @click="petRemarkDialogVisible = false">取消</el-button>
- <el-button type="primary" @click="savePetRemark">保存</el-button>
- </span>
- </template>
- </el-dialog>
- <AddPetDialog v-model:visible="petDialogVisible" :user-id="currentUser.id"
- :user-options="[{ id: currentUser.id, name: currentUser.name, phone: currentUser.phone }]" :locked-owner="true"
- :pet-data="petEditData" @success="onPetSaved" />
- </div>
- </template>
- <script setup>
- import { ref, reactive, computed, onMounted, getCurrentInstance, toRefs } from 'vue'
- import { ElMessage, ElMessageBox } from 'element-plus'
- import { listCustomer, getCustomer, delCustomer, changeCustomerStatus, updateCustomerRemark } from '@/api/archieves/customer'
- import { listPetByUser, delPet, updatePetRemark } from '@/api/archieves/pet'
- import { listAllChangeLog } from '@/api/archieves/changeLog'
- import { listAreaStation } from '@/api/system/areaStation'
- import CustomerDetailDrawer from '@/components/CustomerDetailDrawer/index.vue'
- import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
- import AddCustomerDialog from '@/components/AddCustomerDialog/index.vue'
- import AddPetDialog from '@/components/AddPetDialog/index.vue'
- import { listAllService } from '@/api/service/list/index'
- import { useRegionData } from '@/hooks/useRegionData'
- const { codeToName, loadRegionData } = useRegionData()
- const { proxy } = getCurrentInstance()
- const { sys_user_sex, sys_customer_status } = toRefs(
- proxy?.useDict('sys_user_sex', 'sys_customer_status')
- )
- const loading = ref(false)
- const total = ref(0)
- const allNodes = ref([])
- const loadAreaStation = () => {
- listAreaStation().then((res) => {
- allNodes.value = res.data || []
- })
- }
- const searchAreaValue = ref([])
- const onSearchAreaChange = (value) => {
- if (value && value.length > 0) {
- const lastId = value[value.length - 1];
- const node = allNodes.value.find(item => item.id === lastId);
- searchForm.stationId = lastId;
- searchForm.areaId = node ? node.parentId : undefined;
- } else {
- searchForm.areaId = undefined;
- searchForm.stationId = undefined;
- }
- handleSearch()
- }
- const queryParams = reactive({
- pageNum: 1,
- pageSize: 10,
- keyword: '',
- areaId: undefined,
- stationId: undefined,
- status: undefined
- })
- const searchForm = reactive({
- keyword: '',
- areaId: undefined,
- stationId: undefined
- })
- const customerDialogVisible = ref(false)
- const drawerVisible = ref(false)
- const petDrawerVisible = ref(false)
- const remarkDialogVisible = ref(false)
- const petRemarkDialogVisible = ref(false)
- const petDialogVisible = ref(false)
- const detailActiveTab = ref('info')
- const customerEditData = ref(null)
- const petEditData = ref(null)
- const currentUser = ref({})
- const currentPet = ref({})
- const currentPets = ref([])
- const tableData = ref([])
- const changeLogs = ref([])
- const customerDetailRef = ref(null)
- const serviceList = ref([])
- const getServiceList = () => {
- listAllService().then((res) => {
- serviceList.value = res.data || []
- })
- }
- const remarkForm = reactive({ content: '' })
- const areaTreeOptions = computed(() => {
- const buildTree = (data, parentId) => {
- return data
- .filter(item => String(item.parentId) === String(parentId))
- .map(item => {
- const children = buildTree(data, item.id)
- const node = {
- id: item.id,
- name: item.name,
- // 如果不是站点且没有下级了,则不可选(防止误选区域叶子点)
- disabled: Number(item.type) !== 2 && (!children || children.length === 0)
- }
- if (children.length > 0) node.children = children
- return node
- })
- }
- return buildTree(allNodes.value, 0)
- })
- const getList = () => {
- loading.value = true
- queryParams.keyword = searchForm.keyword
- queryParams.areaId = searchForm.areaId || undefined
- queryParams.stationId = searchForm.stationId || undefined
- listCustomer(queryParams).then((res) => {
- tableData.value = res.rows
- total.value = res.total
- }).finally(() => {
- loading.value = false
- })
- }
- const handleSearch = () => {
- queryParams.pageNum = 1
- getList()
- }
- const handleAdd = () => {
- customerEditData.value = null
- customerDialogVisible.value = true
- }
- const handleEdit = (row) => {
- getCustomer(row.id).then((res) => {
- customerEditData.value = res.data
- customerDialogVisible.value = true
- })
- }
- const onCustomerSaved = () => {
- customerDialogVisible.value = false
- getList()
- if (drawerVisible.value) {
- loadDetailLogs(currentUser.value.id, 'customer')
- }
- }
- const openAddPet = () => {
- petEditData.value = null
- petDialogVisible.value = true
- }
- const handlePetEdit = (row) => {
- petEditData.value = row
- petDialogVisible.value = true
- }
- const onPetSaved = () => {
- petDialogVisible.value = false
- customerDetailRef.value?.refresh()
- getList()
- }
- const handleDetail = (row) => {
- getCustomer(row.id).then((res) => {
- const data = res.data
- // Convert areaId to area name
- if (data.areaId) {
- const area = allNodes.value.find(n => n.id === data.areaId)
- data.areaName = area ? area.name : '-'
- } else {
- data.areaName = '-'
- }
- // Convert stationId to station name
- if (data.stationId) {
- const station = allNodes.value.find(n => n.id === data.stationId)
- data.stationName = station ? station.name : '-'
- } else {
- data.stationName = '-'
- }
- currentUser.value = data
- detailActiveTab.value = 'info'
- drawerVisible.value = true
- })
- }
- const loadDetailPets = (userId) => {
- listPetByUser(userId).then((res) => {
- currentPets.value = res.data || []
- })
- }
- const loadDetailLogs = (targetId, targetType) => {
- listAllChangeLog(targetId, targetType).then((res) => {
- changeLogs.value = res.data || []
- })
- }
- const handleRemark = (row) => {
- currentUser.value = row
- remarkForm.content = ''
- remarkDialogVisible.value = true
- }
- const saveRemark = () => {
- if (!remarkForm.content) return ElMessage.warning('请输入内容')
- const data = {
- id: currentUser.value.id,
- content: remarkForm.content
- }
- updateCustomerRemark(data).then(() => {
- ElMessage.success('备注添加成功')
- remarkDialogVisible.value = false
- getList()
- // 刷新 drawer 中的变更日志
- if (drawerVisible.value) {
- loadDetailLogs(currentUser.value.id, 'customer')
- }
- })
- }
- const savePetRemark = () => {
- if (!remarkForm.content) return ElMessage.warning('请输入内容')
- const data = {
- petId: currentPet.value.id,
- content: remarkForm.content
- }
- updatePetRemark(data).then(() => {
- ElMessage.success('宠物备注添加成功')
- petRemarkDialogVisible.value = false
- if (drawerVisible.value) {
- loadDetailPets(currentUser.value.id)
- loadDetailLogs(currentUser.value.id, 'customer')
- }
- getList()
- })
- }
- const handleDelete = (row) => {
- ElMessageBox.confirm('确认删除该用户档案吗?', '提示', { type: 'warning' }).then(() => {
- delCustomer(row.id).then(() => {
- ElMessage.success('删除成功')
- getList()
- })
- })
- }
- const handleStatusChange = (row) => {
- const statusText = row.status === 0 ? '启用' : '停用'
- ElMessageBox.confirm(`确认${statusText}用户 "${row.name}" 吗?`, '提示', { type: 'warning' }).then(() => {
- changeCustomerStatus(row.id, row.status).then(() => {
- ElMessage.success(`${statusText}成功`)
- })
- }).catch(() => {
- row.status = row.status === 0 ? 1 : 0
- })
- }
- const handleCommand = (command, row) => {
- if (command === 'remark') {
- handleRemark(row)
- } else if (command === 'delete') {
- handleDelete(row)
- }
- }
- const handlePetDetail = (row) => {
- currentPet.value = row
- petDrawerVisible.value = true
- }
- const handlePetRemark = (row) => {
- currentPet.value = row
- remarkForm.content = ''
- petRemarkDialogVisible.value = true
- }
- const handlePetDelete = (row) => {
- ElMessageBox.confirm(`确认删除宠物 [${row.name}] 吗?`, '提示', { type: 'warning' }).then(() => {
- delPet(row.id).then(() => {
- ElMessage.success('宠物删除成功')
- loadDetailPets(currentUser.value.id)
- getList()
- })
- })
- }
- onMounted(() => {
- getList()
- loadAreaStation()
- loadRegionData()
- getServiceList()
- })
- </script>
- <style scoped>
- .page-container {
- padding: 20px;
- }
- .card-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .title {
- font-weight: bold;
- }
- .profile-header {
- display: flex;
- align-items: center;
- margin-bottom: 20px;
- padding-bottom: 20px;
- border-bottom: 1px solid #f0f0f0;
- }
- .profile-basic {
- margin-left: 20px;
- }
- .name-row {
- display: flex;
- align-items: center;
- }
- .name {
- font-size: 20px;
- font-weight: bold;
- color: #303133;
- }
- .phone {
- margin-left: 10px;
- color: #666;
- }
- .section-title {
- font-size: 16px;
- font-weight: bold;
- margin-bottom: 15px;
- border-left: 4px solid #409EFF;
- padding-left: 10px;
- line-height: 1.2;
- }
- .form-section-header {
- font-weight: bold;
- margin-bottom: 15px;
- margin-top: 10px;
- padding-bottom: 5px;
- border-bottom: 1px dashed #eee;
- color: #303133;
- }
- .upload-avatar:hover {
- cursor: pointer;
- opacity: 0.8;
- }
- .pagination-container {
- margin-top: 20px;
- display: flex;
- justify-content: flex-end;
- }
- /* Add Upload Styles */
- .avatar-uploader .el-upload {
- cursor: pointer;
- position: relative;
- overflow: hidden;
- display: inline-block;
- }
- .avatar-uploader-icon {
- font-size: 28px;
- color: #8c939d;
- width: 100px;
- height: 100px;
- text-align: center;
- border: 1px dashed #dcdfe6;
- border-radius: 4px;
- display: flex;
- justify-content: center;
- align-items: center;
- transition: .2s;
- }
- .avatar-uploader-icon:hover {
- border-color: #409EFF;
- color: #409EFF;
- }
- .avatar {
- width: 100px;
- height: 100px;
- display: block;
- border-radius: 4px;
- }
- </style>
|