diff --git a/README.md b/README.md index 52e6d73..5bbbd8f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# 管理员后台pc端 + # 007UI 后台管理系统 一个基于Vue 3、Element Plus的现代化后台管理系统模板,采用蓝色扁平化高端设计风格。 diff --git a/src/assets/7.wav b/src/assets/7.wav new file mode 100644 index 0000000..2050e2f Binary files /dev/null and b/src/assets/7.wav differ diff --git a/src/components/UserSelector/index.vue b/src/components/UserSelector/index.vue new file mode 100644 index 0000000..6be488f --- /dev/null +++ b/src/components/UserSelector/index.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/src/config/menus.js b/src/config/menus.js index 9cfc27e..24093e7 100644 --- a/src/config/menus.js +++ b/src/config/menus.js @@ -5,20 +5,20 @@ export const menus = [ icon: 'DataBoard' }, { - path : '/ticket', + path: '/ticket', title: '工单处理', icon: 'DataBoard' }, { - path:'/user', + path: '/user', title: '用户管理', icon: 'User', children: [ - { + { path: '/user/list', title: '用户列表' }, - { + { path: '/user/balance', title: '用户余额管理' }, @@ -45,10 +45,7 @@ export const menus = [ path: '/product/group', title: '商品分组' }, - { - path: '/product/parameter', - title: '商品参数' - } + ] }, { @@ -75,36 +72,7 @@ export const menus = [ path: '/marketing/voucher', title: '代金券管理' }, - { - path: '/marketing/user-distribution', - title: '用户分发管理' - }, - - { - id: 'discount-goods', - title: '商品关联管理', - path: '/marketing/discount-goods', - badge: 'NEW' - }, - { - id: 'discount-users', - title: '用户关联管理', - path: '/marketing/discount-users', - badge: 'NEW' - }, - - { - id: 'user-info', - title: '用户信息管理', - path: '/marketing/user-info', - badge: 'NEW' - }, - { - id: 'user-history', - title: '用户使用记录管理', - path: '/marketing/user-history', - badge: 'NEW' - } + ] }, { @@ -141,9 +109,9 @@ export const menus = [ { path: '/acs/images/categories', title: '镜像分类' } ] }, - { - path: '/acs/nodes', - title: '节点管理' + { + path: '/acs/nodes', + title: '节点管理' }, { path: '/acs/guacamole', @@ -158,10 +126,10 @@ export const menus = [ ] }, { - path:'/setting', - title:'全局设置管理', - children:[ - {path:'/setting/global',title:'全局设置'} + path: '/setting', + title: '全局设置管理', + children: [ + { path: '/setting/global', title: '全局设置' } ] } ] @@ -171,31 +139,31 @@ export const menus = [ title: '系统管理', icon: 'Setting', children: [ - { - path: '/system/permission', + { + path: '/system/permission', title: '权限管理', children: [ { path: '/system/permission/route', title: '路由权限' }, { path: '/system/permission/admin', title: '管理员权限' } ] }, - - { - path: '/system/file', - title: '文件管理' + + { + path: '/system/file', + title: '文件管理' }, - - { - path: '/system/domain-whitelist', - title: '域名白名单' + + { + path: '/system/domain-whitelist', + title: '域名白名单' }, - { - path: '/system/setting-group', - title: '配置组管理' + { + path: '/system/setting-group', + title: '配置组管理' }, - { - path: '/system/setting-list', - title: '配置管理' + { + path: '/system/setting-list', + title: '配置管理' } ] } diff --git a/src/router/index.js b/src/router/index.js index 086a24e..571f32a 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -230,14 +230,7 @@ const routes = [ title: '商品分组' } }, - { - path: 'parameter', - name: 'ProductParameter', - component: () => import('../views/product/ProductParameter.vue'), - meta: { - title: '商品参数' - } - } + ] }, // 订单管理路由 @@ -287,49 +280,16 @@ const routes = [ } }, { - path: 'user-distribution', - name: 'UserDistribution', - component: () => import('../views/marketing/UserVoucher.vue'), + path: 'voucher/:id/manage', + name: 'VoucherManagement', + component: () => import('../views/marketing/VoucherManagement.vue'), meta: { - title: '用户分发管理' + title: '代金券详情管理', + hidden: true, + activeMenu: '/marketing/voucher' } }, - { - path: 'discount-goods', - name: 'DiscountGoods', - component: () => import('../views/marketing/DiscountGoods.vue'), - meta: { - title: '商品关联管理', - badge: 'NEW' - } - }, - { - path: 'discount-users', - name: 'DiscountUsers', - component: () => import('../views/marketing/DiscountUsers.vue'), - meta: { - title: '用户关联管理', - badge: 'NEW' - } - }, - { - path: 'user-info', - name: 'UserInfo', - component: () => import('../views/marketing/VoucherHolders.vue'), - meta: { - title: '用户信息管理', - badge: 'NEW' - } - }, - { - path: 'user-history', - name: 'UserHistory', - component: () => import('../views/marketing/VoucherHistory.vue'), - meta: { - title: '用户使用记录管理', - badge: 'NEW' - } - } + ] }, // 活动管理路由 diff --git a/src/utils/tool.js b/src/utils/tool.js index 3d0a39f..c54e2dd 100644 --- a/src/utils/tool.js +++ b/src/utils/tool.js @@ -51,4 +51,9 @@ export function timeToTimestamp(time) { } return Math.floor(timestamp / 1000); // 返回毫秒级时间戳(如 1751107200000) + } + + + export function reducenum(num){ + return num / 100 } \ No newline at end of file diff --git a/src/views/marketing/DiscountGoods.vue b/src/views/marketing/DiscountGoods.vue index 6bc2d1c..40ce59b 100644 --- a/src/views/marketing/DiscountGoods.vue +++ b/src/views/marketing/DiscountGoods.vue @@ -5,8 +5,8 @@
- - + + - + - + @@ -166,8 +167,10 @@
+ + + diff --git a/src/views/product/ProductList.vue b/src/views/product/ProductList.vue index 1c29ce7..d7ff893 100644 --- a/src/views/product/ProductList.vue +++ b/src/views/product/ProductList.vue @@ -82,8 +82,8 @@ @@ -99,7 +99,7 @@ @@ -125,6 +125,7 @@ v-model="dialogVisible" :title="dialogType === 'add' ? '新增商品' : '编辑商品'" width="700px" + style="margin-top: 300px;" > - - + + @@ -181,6 +182,152 @@ 确定 + + + +
+
+ + 新增参数 + + + 刷新 + +
+
+ + + + + + + + + + + +
+ + + + + + + + + + 字符串 + 数字 + 选择 + + + + + + + + +
+ 参数:{{ currentParam?.name }} + + 添加参数值 + +
+ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
@@ -189,8 +336,16 @@ import { ref, reactive, onMounted } from 'vue' import { getFileDetail } from '@/api/admin/file' import { ElMessage, ElMessageBox } from 'element-plus' import { Plus, Delete, Search, Refresh } from '@element-plus/icons-vue' -import { getProductList, createProduct, updateProduct, deleteProduct } from '@/api/admin/product' -import { getProductGroupList } from '@/api/admin/product' +import { getProductList, createProduct, updateProduct, deleteProduct, getProductGroupList, + getProductParameterList, + getProductParameterDetail, + createProductParameter, + updateProductParameter, + deleteProductParameter, + addProductParameterValue, + updateProductParameterValue, + deleteProductParameterValue +} from '@/api/admin/product' // 查询参数 const queryParams = reactive({ @@ -344,6 +499,7 @@ const handleAdd = () => { // 编辑商品 const handleEdit = (row) => { + console.log("更新:",row) dialogType.value = 'edit' dialogVisible.value = true Object.assign(productForm, { @@ -353,7 +509,7 @@ const handleEdit = (row) => { content: row.content, cover_id: row.coverId, good_group_id: row.goodGroupId, - inventory_control: row.inventory_control, + inventory_control: row.inventoryControl, inventory: row.inventory, price: row.price, pay_num: row.payNum, @@ -449,10 +605,12 @@ const submitForm = () => { good_group_id: Number(productForm.good_group_id), // 确保是数字类型 cover_id: productForm.cover_id || 0, inventory: productForm.inventory || 0, - price: productForm.price || 0, + price: productForm.price/100 || 0, pay_num: productForm.pay_num || 1, expire_time: productForm.expire_time || 0, - recommend_rebate: productForm.recommend_rebate || 0 + recommend_rebate: productForm.recommend_rebate || 0, + inventory_control:productForm.inventoryControl + } console.log('提交的数据:', submitData) // 调试日志 @@ -480,6 +638,259 @@ onMounted(() => { fetchProductList() fetchGroupList() }) + +// --------------------------------------------------------------------- +// 参数管理相关逻辑 +// --------------------------------------------------------------------- + +// 状态 +const paramDialogVisible = ref(false) +const paramLoading = ref(false) +const parameterList = ref([]) +const currentProductId = ref(null) + +const paramFormDialogVisible = ref(false) +const paramFormType = ref('add') +const paramFormRef = ref(null) +const paramForm = reactive({ + arg_id: undefined, + arg_name: '', + arg_type: 'string' +}) + +const paramRules = { + arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }], + arg_type: [{ required: true, message: '请选择参数类型', trigger: 'change' }] +} + +const paramValuesDialogVisible = ref(false) +const paramValuesLoading = ref(false) +const paramValueList = ref([]) +const currentParam = ref(null) + +const paramValueFormDialogVisible = ref(false) +const paramValueFormType = ref('add') +const paramValueFormRef = ref(null) +const paramValueForm = reactive({ + attr_id: undefined, + attr_name: '', + attr_value: '', + attr_price: 0 +}) + +const paramValueRules = { + attr_name: [{ required: true, message: '请输入值名称', trigger: 'blur' }], + attr_value: [{ required: true, message: '请输入值', trigger: 'blur' }], + attr_price: [{ required: true, message: '请输入价格', trigger: 'blur' }] +} + +// 打开参数管理 +const handleParameter = (row) => { + currentProductId.value = row.id + paramDialogVisible.value = true + fetchParameterList() +} + +// 获取参数列表 +const fetchParameterList = async () => { + if (!currentProductId.value) return + paramLoading.value = true + try { + const res = await getProductParameterList({ good_id: currentProductId.value }) + if (res.data.code === 200) { + parameterList.value = res.data.data || [] + } + } catch (error) { + ElMessage.error('获取参数列表失败') + } finally { + paramLoading.value = false + } +} + +// 参数类型显示 +const getArgTypeText = (type) => { + const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' } + return typeMap[type] || '未知' +} + +const getArgTypeTag = (type) => { + const tagMap = { 'string': 'primary', 'number': 'success', 'select': 'warning' } + return tagMap[type] || 'info' +} + +// 新增参数 +const handleAddParameter = () => { + paramFormType.value = 'add' + paramFormDialogVisible.value = true + Object.assign(paramForm, { + arg_id: undefined, + arg_name: '', + arg_type: 'string' + }) + paramFormRef.value?.resetFields() +} + +// 编辑参数 +const handleEditParameter = (row) => { + paramFormType.value = 'edit' + paramFormDialogVisible.value = true + Object.assign(paramForm, { + arg_id: row.id, + arg_name: row.name, + arg_type: row.type + }) +} + +// 删除参数 +const handleDeleteParameter = (row) => { + ElMessageBox.confirm(`确认删除参数 ${row.name} 吗?`, '警告', { + confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' + }).then(async () => { + try { + const res = await deleteProductParameter({ good_id: currentProductId.value, arg_id: row.id }) + if (res.data.code === 200) { + ElMessage.success('删除成功') + fetchParameterList() + } + } catch (error) { + ElMessage.error('删除失败') + } + }).catch(() => {}) +} + +// 提交参数表单 +const submitParamForm = () => { + paramFormRef.value?.validate(async (valid) => { + if (valid) { + try { + const submitData = { + good_id: Number(currentProductId.value), + arg_name: paramForm.arg_name, + arg_type: paramForm.arg_type + } + if (paramFormType.value === 'edit') { + submitData.arg_id = paramForm.arg_id + } + + const res = paramFormType.value === 'add' + ? await createProductParameter(submitData) + : await updateProductParameter(submitData) + + if (res.data.code === 200) { + ElMessage.success(paramFormType.value === 'add' ? '新增成功' : '修改成功') + paramFormDialogVisible.value = false + fetchParameterList() + } + } catch (error) { + ElMessage.error(error.response?.data?.message || '操作失败') + } + } + }) +} + +// 查看参数值 +const handleViewParamValues = (row) => { + currentParam.value = row + paramValuesDialogVisible.value = true + fetchParamValuesList() +} + +// 获取参数值列表 +const fetchParamValuesList = async () => { + if (!currentProductId.value || !currentParam.value) return + paramValuesLoading.value = true + try { + const res = await getProductParameterDetail({ + good_id: currentProductId.value, + arg_id: currentParam.value.id + }) + if (res.data.code === 200) { + paramValueList.value = res.data.data.attrs || [] + } + } catch (error) { + ElMessage.error('获取参数值列表失败') + } finally { + paramValuesLoading.value = false + } +} + +// 添加参数值 +const handleAddParamValue = () => { + paramValueFormType.value = 'add' + paramValueFormDialogVisible.value = true + Object.assign(paramValueForm, { + attr_id: undefined, + attr_name: '', + attr_value: '', + attr_price: 0 + }) + paramValueFormRef.value?.resetFields() +} + +// 编辑参数值 +const handleEditParamValue = (row) => { + paramValueFormType.value = 'edit' + paramValueFormDialogVisible.value = true + Object.assign(paramValueForm, { + attr_id: row.id, + attr_name: row.name, + attr_value: row.value, + attr_price: row.price / 100 + }) +} + +// 删除参数值 +const handleDeleteParamValue = (row) => { + ElMessageBox.confirm(`确认删除参数值 ${row.name} 吗?`, '警告', { + confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' + }).then(async () => { + try { + const res = await deleteProductParameterValue({ + good_id: currentProductId.value, + attr_id: row.id + }) + if (res.data.code === 200) { + ElMessage.success('删除成功') + fetchParamValuesList() + } + } catch (error) { + ElMessage.error('删除失败') + } + }).catch(() => {}) +} + +// 提交参数值表单 +const submitParamValueForm = () => { + paramValueFormRef.value?.validate(async (valid) => { + if (valid) { + try { + const submitData = { + good_id: Number(currentProductId.value), + arg_id: Number(currentParam.value.id), + attr_name: paramValueForm.attr_name, + attr_value: paramValueForm.attr_value, + attr_price: paramValueForm.attr_price + } + if (paramValueFormType.value === 'edit') { + submitData.attr_id = paramValueForm.attr_id + } + + const res = paramValueFormType.value === 'add' + ? await addProductParameterValue(submitData) + : await updateProductParameterValue(submitData) + + if (res.data.code === 200) { + ElMessage.success(paramValueFormType.value === 'add' ? '添加成功' : '修改成功') + paramValueFormDialogVisible.value = false + fetchParamValuesList() + } + } catch (error) { + ElMessage.error(error.response?.data?.message || '操作失败') + } + } + }) +} + diff --git a/src/views/product/ProductParameter.vue b/src/views/product/ProductParameter.vue deleted file mode 100644 index 495a829..0000000 --- a/src/views/product/ProductParameter.vue +++ /dev/null @@ -1,771 +0,0 @@ - - - - - diff --git a/src/views/ticket/TicketChat.vue b/src/views/ticket/TicketChat.vue index 67875c6..0de9642 100644 --- a/src/views/ticket/TicketChat.vue +++ b/src/views/ticket/TicketChat.vue @@ -33,7 +33,7 @@ @click="selectTicket(ticket)" >
- {{ ticket.username.charAt(0) }} + {{ ticket.username.charAt(0) }}
@@ -96,8 +96,8 @@ :class="['message-item', message.isAdmin ? 'message-admin' : message.isSystem ? 'message-system' : 'message-user']" >
- - {{ currentTicket.username.charAt(0) }} + + {{ message.userId === currentTicket.userId ? currentTicket.username.charAt(0) : 'U' }}
@@ -117,7 +117,7 @@
{{ formatMessageTime(message.time) }}
- A + A
@@ -204,21 +204,24 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Search, Plus, Loading } from '@element-plus/icons-vue' import { useRoute, useRouter } from 'vue-router' import { - getTickerList, + getTickerList, getTicketDetail, replyTicket, closeTicket, getUserAvatar, getFileImage, - parseFilesToImages + parseFilesToImages, + getTicketCount } from '@/api/ticket' +import notificationSound from '@/assets/7.wav' +import { useUserStore } from '@/store/userStore' // 路由相关 const route = useRoute() const router = useRouter() -// 管理员ID列表(客服ID) -const adminUserIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] // 假设这些ID是客服ID +// 用户 store +const userStore = useUserStore() // 头像 const adminAvatar = ref('') @@ -271,6 +274,11 @@ const stats = reactive({ isLoadingStats: false }) +// 上一次的待处理数量,用于判断是否有新工单 +const previousPendingCount = ref(0) +// 音频对象 +const audio = new Audio(notificationSound) + // 快捷回复选项 const quickReplies = ref([ { title: '您好,有什么可以帮助您的?', content: '您好,有什么可以帮助您的?' }, @@ -327,8 +335,9 @@ const fetchTicketList = async (append = false) => { const mappedTickets = tickets.map(item => ({ id: item.work_id, title: item.name, - username: `用户${item.user_id}`, // 用户名,真实环境可能需要获取用户信息 - userId: item.user_id, + username: item.user?.userName || `用户${item.user?.userId || 'Unknown'}`, + userId: item.user?.userId, + avatar: item.user?.coverUrl || '', createTime: new Date(item.created_at).toLocaleString(), lastReplyTime: new Date(item.update_time).toLocaleString(), status: convertStatusToString(item.status), @@ -368,44 +377,35 @@ const fetchTicketList = async (append = false) => { } } -// 获取单个状态的工单数量 -const fetchStatusStat = async (status) => { - try { - // 将状态字符串转换为API所需的状态值 - let statusValue = ''; - if (status === 'pending') statusValue = '0'; - else if (status === 'processing') statusValue = '1'; - else if (status === 'replied') statusValue = '2'; - else if (status === 'completed') statusValue = '3'; - - const res = await getTickerList(10, 1, statusValue) // 只请求一条数据,但获取总数 - - if (res.code === 200) { - if (status === '') { - stats.total = res.data.all_count - } else { - stats[status] = res.data.all_count - } - } else { - console.error(`获取${status || '全部'}工单统计失败:`, res.message) - } - } catch (error) { - console.error(`获取${status || '全部'}工单统计出错:`, error) - } -} - // 获取所有状态的工单数量 const fetchAllStats = async () => { stats.isLoadingStats = true try { - // 并行获取各个状态的工单数量 - await Promise.all([ - fetchStatusStat(''), // 获取全部工单数量 - fetchStatusStat('pending'), // 待处理 - fetchStatusStat('processing'), // 处理中 - fetchStatusStat('replied'), // 已回复 - fetchStatusStat('completed') // 已完成 - ]) + const res = await getTicketCount() + if (res.code === 200) { + const data = res.data + + // 检查是否有新工单(待处理数量增加) + if (data.wait_count > previousPendingCount.value && previousPendingCount.value !== 0) { + try { + audio.play().catch(e => console.error('播放提示音失败:', e)) + } catch (e) { + console.error('播放提示音出错:', e) + } + } + + // 更新上一次的数量 + previousPendingCount.value = data.wait_count + + stats.total = data.all_count + stats.pending = data.wait_count + stats.replied = data.reply_count + stats.completed = data.close_count + // 计算处理中的数量:总数 - 待处理 - 已回复 - 已完成 + stats.processing = data.all_count - data.wait_count - data.reply_count - data.close_count + } else { + console.error('获取工单统计失败:', res.message) + } } catch (error) { console.error('获取工单统计数据出错:', error) } finally { @@ -413,18 +413,12 @@ const fetchAllStats = async () => { } } -// 只刷新当前分类的统计数据(用于定时刷新,减少请求) +// 刷新统计数据(用于定时刷新) const fetchCurrentStatusStat = async () => { - try { - // 只获取当前选中分类的统计数据 - await fetchStatusStat(activeStatus.value) - // 同时获取全部工单数量(因为顶部显示需要) - await fetchStatusStat('') - } catch (error) { - console.error('获取当前分类统计数据出错:', error) - } + await fetchAllStats() } + // 加载更多工单 const loadMoreTickets = () => { if (!hasMore.value || isLoading.value) return @@ -476,9 +470,9 @@ const filteredTickets = computed(() => { }) }) -// 判断是否是客服 +// 判断是否是当前登录的管理员 const isAdmin = (userId) => { - return adminUserIds.includes(userId) + return userId === userStore.userInfo?.user_id } // 状态转换 @@ -540,25 +534,25 @@ const fetchTicketMessages = async (workId) => { } // 处理消息列表 - if (detail.Content && detail.Content.length > 0) { - // 使用Promise.all一次性处理所有消息和图片 - const messagesPromises = detail.Content.map(async (msg) => { - const isAdminMsg = isAdmin(msg.UserId) - const images = await parseFilesToImages(msg.Flies) + if (detail.content && detail.content.length > 0) { + // 处理所有消息 + const messages = detail.content.map((msg) => { + const isAdminMsg = isAdmin(msg.user?.userId) + // 从 flies 数组中提取图片 URL + const images = msg.flies ? msg.flies.map(file => file.url) : [] return { - id: msg.Id, - content: msg.Content !== 'empty' ? msg.Content : null, + id: msg.id, + content: msg.content !== 'empty' ? msg.content : null, images: images, - time: new Date(msg.CreatedAt).toLocaleString(), + time: msg.created_at || msg.updated_at || new Date().toLocaleString(), isAdmin: isAdminMsg, isSystem: false, - userId: msg.UserId + userId: msg.user?.userId, + avatar: msg.user?.coverUrl || '' } }) - // 等待所有消息处理完成 - const messages = await Promise.all(messagesPromises) currentMessages.value = messages } } else { @@ -589,23 +583,29 @@ const sendMessage = async () => { const fileIds = [] try { - // 添加一个临时的"正在发送"消息 + // 保存输入内容 + const inputMsg = messageInput.value.trim() + const inputImages = [...selectedImages.value] + + // 清空输入和已选图片 + messageInput.value = '' + selectedImages.value = [] + + // 立即添加消息到界面(不显示 loading) const tempMsg = { - content: messageInput.value.trim() || null, - images: selectedImages.value.length > 0 ? [...selectedImages.value] : null, + id: Date.now(), // 临时 ID + content: inputMsg || null, + images: inputImages.length > 0 ? inputImages : [], time: new Date().toLocaleString(), isAdmin: true, - isLoading: true, + isSystem: false, + userId: userStore.userInfo?.user_id, + avatar: userStore.userInfo?.cover_url || '', isTempMessage: true } currentMessages.value.push(tempMsg) - // 清空输入和已选图片 - const inputMsg = messageInput.value - messageInput.value = '' - selectedImages.value = [] - // 滚动到底部 await nextTick() scrollToBottom() @@ -633,6 +633,7 @@ const sendMessage = async () => { // 恢复输入内容 messageInput.value = inputMsg + selectedImages.value = inputImages ElMessage.error(res.message || '发送失败') } @@ -729,42 +730,149 @@ const updateTicketStats = () => { // 格式化消息时间 const formatMessageTime = (timeStr) => { - const date = new Date(timeStr) - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + if (!timeStr) return '' + + try { + const date = new Date(timeStr) + if (isNaN(date.getTime())) return '' + + // 格式化为 HH:MM + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${hours}:${minutes}` + } catch (e) { + console.error('时间格式化失败:', e) + return '' + } } // 格式化列表项时间 const formatTime = (timeStr) => { - const date = new Date(timeStr) - const now = new Date() - const diff = now - date - - // 今天内的消息只显示时间 - if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + if (!timeStr) return ''; // 空值兜底 + + // 步骤1:解析中文时间字符串(核心适配点) + let date; + try { + // 先尝试原生解析(兼容ISO格式) + date = new Date(timeStr); + // 若原生解析失败(返回Invalid Date),解析中文格式 + if (isNaN(date.getTime())) { + // 正则提取中文时间的年、月、日、时、分、秒 + const cnTimeMatch = timeStr.match( + /(\d{4})年(\d{1,2})月(\d{1,2})日\s*(上午|下午)\s*(\d{1,2}):(\d{1,2}):(\d{1,2})/ + ); + if (cnTimeMatch) { + const [, year, month, day, period, hour, minute, second] = cnTimeMatch; + // 处理下午/上午的小时转换(12小时制转24小时制) + let hour24 = parseInt(hour, 10); + if (period === '下午' && hour24 !== 12) { + hour24 += 12; + } + if (period === '上午' && hour24 === 12) { + hour24 = 0; // 上午12点转为0点 + } + // 构造日期(月份从0开始,需-1) + date = new Date( + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(day, 10), + hour24, + parseInt(minute, 10), + parseInt(second, 10) + ); + } else { + return '无效时间'; // 既不是ISO也不是中文格式 + } + } + } catch (e) { + console.error('时间解析失败:', e); + return '无效时间'; } + + const now = new Date(); + const dateTime = date.getTime(); + const nowTime = now.getTime(); + const diff = nowTime - dateTime; + + // 步骤2:判断“今天”(年/月/日完全一致) + const isToday = date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); - // 一周内的显示星期几 - if (diff < 7 * 24 * 60 * 60 * 1000) { - const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] - return weekdays[date.getDay()] + if (isToday) { + // 格式化今天的时间(24小时制,补零) + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + return `${hour}:${minute}`; } - - // 其他显示日期 - return date.toLocaleDateString() -} + + // 步骤3:判断“一周内” + const oneWeek = 7 * 24 * 60 * 60 * 1000; + if (diff < oneWeek) { + const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + return weekdays[date.getDay()]; + } + + // 步骤4:格式化其他日期(补零,统一格式:YYYY/MM/DD) + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}/${month}/${day}`; +}; // 格式化日期显示 const formatDate = (timeStr) => { - const date = new Date(timeStr) - const now = new Date() - - if (date.toDateString() === now.toDateString()) { - return '今天' + console.log("原始时间字符串:", timeStr); + if (!timeStr) return ''; // 空值兜底 + + let date; + // 1. 先尝试原生解析(兼容ISO等标准格式) + date = new Date(timeStr); + + // 2. 若原生解析失败,专门解析中文时间格式 + if (isNaN(date.getTime())) { + // 正则匹配:xxxx年xx月xx日 上午/下午 xx:xx:xx + const cnTimeReg = /(\d{4})年(\d{1,2})月(\d{1,2})日\s*(上午|下午)\s*(\d{1,2}):(\d{1,2}):(\d{1,2})/; + const match = timeStr.match(cnTimeReg); + + if (match) { + const [, year, month, day, period, hour, minute, second] = match; + // 处理12小时制转24小时制(关键适配) + let hour24 = parseInt(hour, 10); + if (period === '下午') { + hour24 = hour24 === 12 ? 12 : hour24 + 12; // 下午12点=12点,下午1-11点+12 + } else { // 上午 + hour24 = hour24 === 12 ? 0 : hour24; // 上午12点=0点,上午1-11点不变 + } + // 手动构造合法的Date对象(月份从0开始,需-1) + date = new Date( + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(day, 10), + hour24, + parseInt(minute, 10), + parseInt(second, 10) + ); + } else { + return '无效时间'; // 非目标格式,返回兜底 + } } - - return date.toLocaleDateString() -} + + const now = new Date(); + // 3. 对比“今天”(按日期维度,忽略时分秒) + const isToday = date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + + if (isToday) { + return '今天'; + } + + // 4. 格式化非今天的日期(统一格式,避免环境差异) + const formattedDate = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; + + return formattedDate; +}; // 监听显示工单变化,更新消息和滚动 watch(currentTicket, (newVal) => { @@ -838,8 +946,9 @@ const refreshTicketList = async () => { const mappedTickets = tickets.map(item => ({ id: item.work_id, title: item.name, - username: `用户${item.user_id}`, - userId: item.user_id, + username: item.user?.userName || `用户${item.user?.userId || 'Unknown'}`, + userId: item.user?.userId, + avatar: item.user?.coverUrl || '', createTime: new Date(item.created_at).toLocaleString(), lastReplyTime: new Date(item.update_time).toLocaleString(), status: convertStatusToString(item.status), @@ -874,6 +983,39 @@ const refreshTicketList = async () => { } } +// 辅助函数:去除 URL 中的查询参数 +const normalizeUrl = (url) => { + if (!url) return '' + return url.split('?')[0] +} + +// 辅助函数:比较两个消息数组是否相同(忽略 URL 查询参数) +const areMessagesEqual = (messages1, messages2) => { + if (messages1.length !== messages2.length) return false + + for (let i = 0; i < messages1.length; i++) { + const msg1 = messages1[i] + const msg2 = messages2[i] + + // 比较消息 ID 和内容 + if (msg1.id !== msg2.id || msg1.content !== msg2.content) return false + + // 比较图片数量 + if ((msg1.images?.length || 0) !== (msg2.images?.length || 0)) return false + + // 比较图片 URL(去除查询参数) + if (msg1.images && msg2.images) { + for (let j = 0; j < msg1.images.length; j++) { + if (normalizeUrl(msg1.images[j]) !== normalizeUrl(msg2.images[j])) { + return false + } + } + } + } + + return true +} + // 静默刷新聊天记录(不显示loading状态) const refreshTicketMessages = async (workId) => { try { @@ -886,37 +1028,33 @@ const refreshTicketMessages = async (workId) => { if (currentTicket.value) { // 只有非待处理状态才直接更新,待处理状态保持不变,等待回复后再更新 if (currentTicket.value.status !== 'pending') { - currentTicket.value.status = convertStatusToString(detail.Status) + currentTicket.value.status = convertStatusToString(detail.status) } } // 处理消息列表 - if (detail.Content && detail.Content.length > 0) { - // 检查是否有新消息 - const lastMsgId = currentMessages.value.length > 0 ? - currentMessages.value[currentMessages.value.length - 1].id : 0; - const hasNewMessage = detail.Content.some(msg => msg.Id > lastMsgId); - - if (hasNewMessage) { - // 有新消息时才更新 - const messagesPromises = detail.Content.map(async (msg) => { - const isAdminMsg = isAdmin(msg.UserId) - const images = await parseFilesToImages(msg.Flies) - - return { - id: msg.Id, - content: msg.Content !== 'empty' ? msg.Content : null, - images: images, - time: new Date(msg.CreatedAt).toLocaleString(), - isAdmin: isAdminMsg, - isSystem: false, - userId: msg.UserId - } - }) + if (detail.content && detail.content.length > 0) { + // 构建新消息列表 + const newMessages = detail.content.map((msg) => { + const isAdminMsg = isAdmin(msg.user?.userId) + // 从 flies 数组中提取图片 URL + const images = msg.flies ? msg.flies.map(file => file.url) : [] - // 等待所有消息处理完成 - const messages = await Promise.all(messagesPromises) - currentMessages.value = messages + return { + id: msg.id, + content: msg.content !== 'empty' ? msg.content : null, + images: images, + time: msg.created_at || msg.updated_at || new Date().toLocaleString(), + isAdmin: isAdminMsg, + isSystem: false, + userId: msg.user?.userId, + avatar: msg.user?.coverUrl || '' + } + }) + + // 只有在消息真正发生变化时才更新(忽略 URL 查询参数的变化) + if (!areMessagesEqual(currentMessages.value, newMessages)) { + currentMessages.value = newMessages // 如果有新消息,滚动到底部 nextTick(() => { diff --git a/src/views/user/UserDetail.vue b/src/views/user/UserDetail.vue index 266c548..3784779 100644 --- a/src/views/user/UserDetail.vue +++ b/src/views/user/UserDetail.vue @@ -304,23 +304,37 @@ - +
- - - -
- -
- 复制 -
-
+ + + + +
+ 正式环境 + www.007yjs.com +
+
+ +
+ 测试环境 + apiserver.s1f.ren +
+
+ +
+ 本地环境 + localhost:5173 +
+
+
+
+
@@ -364,7 +378,7 @@ import AvatarSelector from '@/components/admin/AvatarSelector.vue' import { ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock, UserFilled, Document, Clock, List, Switch, User, Camera, Upload, - UploadFilled, Key, CopyDocument + UploadFilled, Key, Monitor, Setting } from '@element-plus/icons-vue' import { getUserGroupList } from '@/api/admin/user' import { getFileDetail, getFileList, getFile, uploadFile } from '@/api/admin/file' @@ -465,6 +479,15 @@ const realnameForm = reactive({ // Token 展示相关 const tokenDialogVisible = ref(false) const loginToken = ref('') +const loginExpire = ref('') +const selectedEnvironment = ref('') + +// 环境配置 +const environments = { + production: 'https://www.007yjs.com', + test: 'https://apiserver.s1f.ren', + local: 'http://localhost:5173' +} // 管理员权限管理相关 const adminDialogVisible = ref(false) @@ -729,8 +752,10 @@ const handleSimulateLogin = async () => { const res = await mockUserLogin({ user_id: userInfo.value.UserId }) if (res.data.code === 200) { loginToken.value = res.data.data.token || '' + loginExpire.value = res.data.data.expire_time || '' + selectedEnvironment.value = '' tokenDialogVisible.value = true - ElMessage.success('模拟登录成功') + //ElMessage.success('模拟登录成功') } else { ElMessage.error(res.data.message || '模拟登录失败') } @@ -739,14 +764,18 @@ const handleSimulateLogin = async () => { } } -// 复制 Token -const copyToken = async () => { - try { - await navigator.clipboard.writeText(loginToken.value) - ElMessage.success('Token 已复制到剪贴板') - } catch (err) { - ElMessage.error('复制失败,请手动复制') +// 确认跳转 +const confirmJump = () => { + if (!selectedEnvironment.value) { + ElMessage.warning('请选择登录环境') + return } + const baseUrl = environments[selectedEnvironment.value] + const url = `${baseUrl}/token-login?token=${loginToken.value}&expire=${loginExpire.value}` + window.open(url, '_blank') + const envName = selectedEnvironment.value === 'production' ? '正式' : selectedEnvironment.value === 'test' ? '测试' : '本地' + ElMessage.success(`正在跳转到${envName}环境`) + tokenDialogVisible.value = false } // 获取实名状态文本 @@ -1166,6 +1195,28 @@ onActivated(() => { font-size: 13px; } +/* Token Dialog */ +.token-container { + padding: 20px 0; +} + +.env-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; +} + +.env-option span:first-child { + font-size: 14px; + color: #303133; +} + +.env-url { + font-size: 12px; + color: #909399; +} + /* Responsive */ @media (max-width: 1024px) { .detail-grid {