feat(admin): 工单管理 UI 优化与回复模板、文件管理增强
Build and Deploy Vue3 / build (push) Failing after 48s
Build and Deploy Vue3 / deploy (push) Has been skipped

工单列表与详情 UI/交互优化及新工单提醒;新增回复模板与工单类型管理;文件管理增加管理员筛选并优化详情展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shiran
2026-06-02 17:28:11 +08:00
parent 928d14aada
commit c18622226e
12 changed files with 2480 additions and 477 deletions
+38 -8
View File
@@ -5,14 +5,14 @@ import request from "@/utils/request.js";
* @returns {Promise}
*/
export function getTickerList(count, page, status, orderBy, order, userId, keyword) {
export function getTickerList(count, page, status, orderBy, order, userId, keyword, type) {
const params = { count, page }
if (status !== undefined && status !== '') params.status = status
if (orderBy) params.orderBy = orderBy
if (order) params.order = order
if (userId) params.user_id = userId
if (keyword) params.keyword = keyword
console.log('工单列表请求参数:', params) // 调试日志
if (type) params.type = type
return request.get('/api/v1/admin/work_order/list', params)
}
@@ -43,12 +43,16 @@ export function getTicketDetail(work_id) {
// 回复工单
export function replyTicket(work_id, content, files) {
return request.post('/api/v1/admin/work_order/reply', { work_id, content, files })
return request.post('/api/v1/admin/work_order/reply', { work_id, content, files }, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 关闭工单
export function closeTicket(work_id) {
return request.post('/api/v1/admin/work_order/close', { work_id })
return request.post('/api/v1/admin/work_order/close', { work_id }, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export function getFile(file_id) {
@@ -106,10 +110,9 @@ export function updateTicketType(data) {
}
/**删除工单类型 */
export function deleteTicketType(data) {
return request.delete('/api/v1/admin/work_order/delete_type', data,{
headers:{
'Content-Type':'multipart/form-data'
}
return request.delete('/api/v1/admin/work_order/delete_type', {
data: data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**获取工单类型列表 */
@@ -124,3 +127,30 @@ export function updateTicketReplayInfo(data){
}
})
}
/**获取回复模板列表 */
export function getReplyTemplateList(params = {}) {
return request.get('/api/v1/admin/work_order/reply_template/list', params)
}
/**创建回复模板 */
export function createReplyTemplate(data) {
return request.post('/api/v1/admin/work_order/reply_template/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**修改回复模板 */
export function updateReplyTemplate(data) {
return request.post('/api/v1/admin/work_order/reply_template/update', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**删除回复模板 */
export function deleteReplyTemplate(data) {
return request.delete('/api/v1/admin/work_order/reply_template/delete', {
data: data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+3 -18
View File
@@ -45,12 +45,8 @@
<breadcrumb />
</div>
<div class="navbar-right">
<div class="navbar-item hidden-mobile">
<el-tooltip content="全屏" placement="bottom">
<el-button type="text" class="header-btn" @click="toggleFullScreen">
<el-icon :size="18"><full-screen /></el-icon>
</el-button>
</el-tooltip>
<div class="navbar-item">
<GlobalSearch />
</div>
<div class="navbar-item">
@@ -103,9 +99,9 @@ import { useRoute, useRouter } from 'vue-router'
import SidebarMenuItem from './SidebarMenuItem.vue'
import Breadcrumb from './Breadcrumb.vue'
import TagsView from './TagsView.vue'
import GlobalSearch from './GlobalSearch.vue'
import { menus as menuConfig } from '@/config/menus'
import {
FullScreen,
ArrowDown,
User,
Key,
@@ -165,17 +161,6 @@ const closeMobileMenu = () => {
isMobileMenuOpen.value = false
}
// 切换全屏
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
}
// 退出登录
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
+581
View File
@@ -0,0 +1,581 @@
<template>
<div class="global-search">
<el-tooltip content="全局搜索" placement="bottom">
<el-button type="text" class="header-btn" @click="openSearch">
<el-icon :size="18"><Search /></el-icon>
</el-button>
</el-tooltip>
<el-dialog
v-model="visible"
:show-close="false"
:append-to-body="true"
class="search-dialog"
width="680px"
top="12vh"
@opened="focusInput"
>
<div class="search-header">
<el-icon :size="20" class="search-prefix"><Search /></el-icon>
<input
ref="searchInput"
v-model="keyword"
class="search-input"
placeholder="搜索用户、订单、工单、用户商品..."
@keydown.enter="handleSearch"
@keydown.escape="visible = false"
/>
<span v-if="keyword" class="search-clear" @click="clearSearch">
<el-icon :size="16"><CircleClose /></el-icon>
</span>
<span class="search-shortcut">ESC</span>
</div>
<div v-if="hasSearched" class="search-body">
<el-tabs v-model="activeTab" class="search-tabs">
<el-tab-pane name="user">
<template #label>
<span class="tab-label">用户 <em v-if="results.user.total > 0">{{ results.user.total }}</em></span>
</template>
<div class="result-list" v-loading="results.user.loading">
<div v-if="results.user.list.length === 0 && !results.user.loading" class="empty-tip">未找到相关用户</div>
<div
v-for="item in results.user.list"
:key="item.user_id"
class="result-item"
@click="goToUser(item)"
>
<el-avatar :size="32" :src="item.cover || ''" class="result-avatar">
{{ (item.user_name || '')[0] }}
</el-avatar>
<div class="result-info">
<span class="result-title" v-html="highlight(item.user_name)"></span>
<span class="result-desc">ID: {{ item.user_id }} · {{ item.phone || item.email || '—' }}</span>
</div>
<el-icon class="result-arrow"><ArrowRight /></el-icon>
</div>
</div>
<div class="result-pagination" v-if="results.user.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.user.total" :page-size="pageSize" v-model:current-page="results.user.page" @current-change="(p) => { results.user.page = p; searchUsers(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="order">
<template #label>
<span class="tab-label">订单 <em v-if="results.order.total > 0">{{ results.order.total }}</em></span>
</template>
<div class="result-list" v-loading="results.order.loading">
<div v-if="results.order.list.length === 0 && !results.order.loading" class="empty-tip">未找到相关订单</div>
<div
v-for="item in results.order.list"
:key="item.id"
class="result-item"
@click="goToOrder(item)"
>
<div class="result-icon order-icon">
<el-icon :size="18"><Document /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.name || ('#' + item.id))"></span>
<span class="result-desc">用户ID: {{ item.userId }} · ¥{{ (item.price / 100).toFixed(2) }} · {{ item.type }}</span>
</div>
<el-tag size="small" :type="orderStatusType(item.state)">{{ orderStatusText(item.state) }}</el-tag>
</div>
</div>
<div class="result-pagination" v-if="results.order.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.order.total" :page-size="pageSize" v-model:current-page="results.order.page" @current-change="(p) => { results.order.page = p; searchOrders(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="ticket">
<template #label>
<span class="tab-label">工单 <em v-if="results.ticket.total > 0">{{ results.ticket.total }}</em></span>
</template>
<div class="result-list" v-loading="results.ticket.loading">
<div v-if="results.ticket.list.length === 0 && !results.ticket.loading" class="empty-tip">未找到相关工单</div>
<div
v-for="item in results.ticket.list"
:key="item.work_id"
class="result-item"
@click="goToTicket(item)"
>
<div class="result-icon ticket-icon">
<el-icon :size="18"><ChatDotSquare /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.name)"></span>
<span class="result-desc">{{ item.user?.userName || ('用户' + item.user?.userId) }} · {{ formatTime(item.created_at) }}</span>
</div>
<el-tag size="small" :type="ticketStatusType(item.status)">{{ ticketStatusText(item.status) }}</el-tag>
</div>
</div>
<div class="result-pagination" v-if="results.ticket.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.ticket.total" :page-size="pageSize" v-model:current-page="results.ticket.page" @current-change="(p) => { results.ticket.page = p; searchTickets(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="goods">
<template #label>
<span class="tab-label">用户商品 <em v-if="results.goods.total > 0">{{ results.goods.total }}</em></span>
</template>
<div class="result-list" v-loading="results.goods.loading">
<div v-if="results.goods.list.length === 0 && !results.goods.loading" class="empty-tip">未找到相关用户商品</div>
<div
v-for="item in results.goods.list"
:key="item.id"
class="result-item"
@click="goToGoods(item)"
>
<div class="result-icon goods-icon">
<el-icon :size="18"><Box /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
</div>
<el-icon class="result-arrow"><ArrowRight /></el-icon>
</div>
</div>
<div class="result-pagination" v-if="results.goods.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.goods.total" :page-size="pageSize" v-model:current-page="results.goods.page" @current-change="(p) => { results.goods.page = p; searchGoods(keyword.trim()) }" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<div v-else class="search-placeholder">
<el-icon :size="48" class="placeholder-icon"><Search /></el-icon>
<p>输入关键词后按回车搜索</p>
<div class="search-tips">
<span>支持搜索用户名/手机号订单号工单标题商品名称</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, CircleClose, ArrowRight, Document, ChatDotSquare, Box } from '@element-plus/icons-vue'
import { getUserList } from '@/api/admin/user.js'
import { getOrderList } from '@/api/admin/order.js'
import { getTickerList } from '@/api/ticket.js'
import { getUserGoodsList } from '@/api/admin/userVm.js'
const router = useRouter()
const visible = ref(false)
const keyword = ref('')
const activeTab = ref('user')
const hasSearched = ref(false)
const searchInput = ref(null)
const pageSize = 10
const results = reactive({
user: { list: [], total: 0, loading: false, page: 1 },
order: { list: [], total: 0, loading: false, page: 1 },
ticket: { list: [], total: 0, loading: false, page: 1 },
goods: { list: [], total: 0, loading: false, page: 1 }
})
const openSearch = () => {
visible.value = true
}
const focusInput = () => {
searchInput.value?.focus()
}
const clearSearch = () => {
keyword.value = ''
hasSearched.value = false
searchInput.value?.focus()
}
const handleSearch = () => {
const key = keyword.value.trim()
if (!key) return
hasSearched.value = true
results.user.page = 1
results.order.page = 1
results.ticket.page = 1
results.goods.page = 1
searchUsers(key)
searchOrders(key)
searchTickets(key)
searchGoods(key)
}
const searchUsers = async (key) => {
results.user.loading = true
results.user.list = []
try {
const res = await getUserList({ page: results.user.page, count: pageSize, key })
if (res.data?.code === 200) {
results.user.list = res.data.data?.data || []
results.user.total = res.data.data?.all_count || results.user.list.length
}
} catch (e) { /* ignore */ }
results.user.loading = false
}
const searchOrders = async (key) => {
results.order.loading = true
results.order.list = []
try {
const res = await getOrderList({ page: results.order.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.order.list = res.data.data?.list || []
results.order.total = res.data.data?.all_count || results.order.list.length
}
} catch (e) { /* ignore */ }
results.order.loading = false
}
const searchTickets = async (key) => {
results.ticket.loading = true
results.ticket.list = []
try {
const res = await getTickerList(pageSize, results.ticket.page, '', '', '', '', key)
if (res?.code === 200) {
results.ticket.list = res.data?.data || []
results.ticket.total = res.data?.all_count || results.ticket.list.length
}
} catch (e) { /* ignore */ }
results.ticket.loading = false
}
const searchGoods = async (key) => {
results.goods.loading = true
results.goods.list = []
try {
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.goods.list = res.data.data?.data || []
results.goods.total = res.data.data?.all_count || results.goods.list.length
}
} catch (e) { /* ignore */ }
results.goods.loading = false
}
const highlight = (text) => {
if (!text || !keyword.value) return text
const key = keyword.value.trim()
if (!key) return text
const regex = new RegExp(`(${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return String(text).replace(regex, '<mark>$1</mark>')
}
const formatTime = (time) => {
if (!time) return '—'
const d = new Date(time)
if (isNaN(d.getTime())) return '—'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const goToUser = (item) => {
visible.value = false
router.push({ path: '/user/detail', query: { user_id: item.user_id } })
}
const goToOrder = (item) => {
visible.value = false
router.push({ path: '/order/list', query: { keyword: keyword.value } })
}
const goToTicket = (item) => {
visible.value = false
router.push({ path: '/ticket/detail', query: { id: item.work_id } })
}
const goToGoods = (item) => {
visible.value = false
const tag = (item.tag || item.good?.tag || '').toLowerCase()
if (tag === '云服务器') {
router.push({ path: '/user-goods/vm-detail', query: { id: item.id } })
} else {
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
}
}
const orderStatusText = (status) => {
const map = { 0: '待支付', 1: '已完成', 2: '已取消', 3: '已退款' }
return map[status] || '未知'
}
const orderStatusType = (status) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger' }
return map[status] || 'info'
}
const ticketStatusText = (status) => {
const map = { 0: '待处理', 1: '处理中', 2: '已回复', 3: '已关闭' }
return map[status] || '未知'
}
const ticketStatusType = (status) => {
const map = { 0: 'danger', 1: 'warning', 2: 'success', 3: 'info' }
return map[status] || 'info'
}
const handleKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
openSearch()
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped>
.global-search {
display: flex;
align-items: center;
}
.header-btn {
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
color: #34495e;
transition: all 0.2s ease;
border-radius: 0;
}
.header-btn:hover {
background-color: #f8f9fa;
color: #2c3e50;
}
</style>
<style>
.search-dialog .el-dialog__header {
display: none;
}
.search-dialog .el-dialog__body {
padding: 0;
}
.search-dialog .el-dialog {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.search-header {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e8ecf0;
gap: 12px;
}
.search-prefix {
color: #909399;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 16px;
color: #2c3e50;
background: transparent;
line-height: 1.5;
}
.search-input::placeholder {
color: #a8abb2;
}
.search-clear {
cursor: pointer;
color: #909399;
display: flex;
align-items: center;
transition: color 0.2s;
}
.search-clear:hover {
color: #606266;
}
.search-shortcut {
font-size: 12px;
color: #a8abb2;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 2px 6px;
flex-shrink: 0;
}
.search-body {
max-height: 460px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-tabs {
height: 100%;
}
.search-tabs .el-tabs__header {
padding: 0 20px;
margin-bottom: 0;
}
.search-tabs .el-tabs__content {
max-height: 400px;
overflow-y: auto;
}
.tab-label em {
font-style: normal;
font-size: 11px;
background: #409eff;
color: #fff;
border-radius: 8px;
padding: 1px 6px;
margin-left: 4px;
vertical-align: middle;
}
.result-list {
padding: 8px 12px;
min-height: 80px;
}
.result-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
gap: 12px;
}
.result-item:hover {
background: #f5f7fa;
}
.result-avatar {
flex-shrink: 0;
background: #ecf5ff;
color: #409eff;
font-size: 14px;
}
.result-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.order-icon {
background: #fdf6ec;
color: #e6a23c;
}
.ticket-icon {
background: #f0f9eb;
color: #67c23a;
}
.goods-icon {
background: #ecf5ff;
color: #409eff;
}
.result-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.result-title {
font-size: 14px;
color: #2c3e50;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-title :deep(mark) {
background: #fff3cd;
color: #e6a23c;
padding: 0 2px;
border-radius: 2px;
}
.result-desc {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-arrow {
color: #c0c4cc;
flex-shrink: 0;
}
.result-pagination {
display: flex;
justify-content: center;
padding: 8px 12px 12px;
border-top: 1px solid #f0f2f5;
}
.empty-tip {
text-align: center;
color: #909399;
font-size: 14px;
padding: 32px 0;
}
.search-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: #909399;
}
.placeholder-icon {
color: #dcdfe6;
margin-bottom: 12px;
}
.search-placeholder p {
margin: 0 0 8px;
font-size: 14px;
}
.search-tips {
font-size: 12px;
color: #a8abb2;
}
</style>
+8
View File
@@ -12,6 +12,14 @@ export const menus = [
{
path: '/ticket/list',
title: '工单列表'
},
{
path: '/ticket/types',
title: '工单类型'
},
{
path: '/ticket/templates',
title: '回复模板'
}
]
},
+16
View File
@@ -58,6 +58,22 @@ const routes = [
hidden: true,
activeMenu: '/ticket/list'
}
},
{
path: 'types',
name: 'TicketTypes',
component: () => import('../views/ticket/TicketTypes.vue'),
meta: {
title: '工单类型管理'
}
},
{
path: 'templates',
name: 'TicketTemplates',
component: () => import('../views/ticket/TicketTemplates.vue'),
meta: {
title: '回复模板管理'
}
}
]
},
+1 -6
View File
@@ -118,12 +118,7 @@ const domainForm = reactive({
// 表单规则
const domainRules = {
domain: [
{ required: true, message: '请输入域名', trigger: 'blur' },
{
pattern: /^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+[A-Za-z]{2,6}$/,
message: '请输入有效的域名格式',
trigger: 'blur'
}
{ required: true, message: '请输入域名', trigger: 'blur' }
]
}
+228 -72
View File
@@ -12,6 +12,12 @@
<el-form-item label="筛选用户">
<el-input-number v-model="queryParams.user_id" placeholder="请输入用户ID" :controls="false" clearable style="width: 150px" />
</el-form-item>
<el-form-item label="上传来源">
<el-select v-model="queryParams.is_admin" placeholder="全部" clearable style="width: 130px">
<el-option label="管理员" :value="true" />
<el-option label="用户" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
@@ -69,6 +75,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="上传来源" width="100">
<template #default="{ row }">
<el-tag :type="row.isAdmin ? 'warning' : ''">
{{ row.isAdmin ? '管理员' : '用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
@@ -102,65 +115,87 @@
<!-- 文件详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="文件详情"
width="700px"
title=""
width="720px"
destroy-on-close
class="file-detail-dialog"
>
<div v-if="fileDetail" class="file-detail-container">
<!-- 文件预览区域 -->
<div class="file-preview-section">
<div class="preview-label">文件预览</div>
<div class="preview-content">
<!-- 顶部卡片预览 + 核心信息 -->
<div class="detail-top">
<div class="detail-preview">
<el-image
v-if="isImageFile(fileDetail.type, fileDetail.url, fileDetail.realName) && fileDetail.url"
:src="fileDetail.url"
fit="contain"
style="max-width: 100%; max-height: 400px; border-radius: 8px;"
class="preview-image"
:preview-src-list="[fileDetail.url]"
:initial-index="0"
>
<template #error>
<div class="image-error">
<el-icon size="40"><Picture /></el-icon>
<div>图片加载失败</div>
<el-icon size="36"><Picture /></el-icon>
<span>加载失败</span>
</div>
</template>
</el-image>
<div v-else class="file-icon-large">
<el-icon size="80"><Document /></el-icon>
<div class="file-type-text">{{ fileDetail.type || '未知类型' }}</div>
<el-icon size="56" color="#909399"><Document /></el-icon>
</div>
</div>
<div class="detail-summary">
<div class="detail-filename" :title="fileDetail.realName">{{ fileDetail.realName }}</div>
<div class="detail-meta-row">
<el-tag :type="getFileTypeColor(fileDetail.type, fileDetail.url, fileDetail.realName)" size="small">{{ fileDetail.type || '未知' }}</el-tag>
<span class="detail-size">{{ formatFileSize(fileDetail.size) }}</span>
<el-tag :type="fileDetail.openDow ? 'success' : 'info'" size="small">{{ fileDetail.openDow ? '公开' : '私有' }}</el-tag>
<el-tag :type="fileDetail.isAdmin ? 'warning' : ''" size="small">{{ fileDetail.isAdmin ? '管理员' : '用户' }}</el-tag>
</div>
<div class="detail-meta-row secondary">
<span>ID: {{ fileDetail.id }}</span>
<span>用户: {{ fileDetail.userId }}</span>
<span>{{ formatDate(fileDetail.CreatedAt) }}</span>
</div>
<div class="detail-actions">
<el-button v-if="fileDetail.url" type="primary" size="small" @click="openFileUrl(fileDetail.url)">
<el-icon><View /></el-icon>查看原文件
</el-button>
<el-button v-if="fileDetail.url" type="success" size="small" @click="handleDownload(fileDetail)">
<el-icon><Download /></el-icon>下载
</el-button>
<el-button type="default" size="small" @click="copyPath(fileDetail.savePath)">
<el-icon><DocumentCopy /></el-icon>复制路径
</el-button>
</div>
</div>
</div>
<!-- 文件信息 -->
<el-descriptions :column="2" border class="file-info-descriptions">
<el-descriptions-item label="文件ID" label-align="right">{{ fileDetail.id }}</el-descriptions-item>
<el-descriptions-item label="用户ID" label-align="right">{{ fileDetail.userId }}</el-descriptions-item>
<el-descriptions-item label="真实文件名" label-align="right" :span="2">{{ fileDetail.realName }}</el-descriptions-item>
<el-descriptions-item label="保存名称" label-align="right">{{ fileDetail.saveName }}</el-descriptions-item>
<el-descriptions-item label="文件类型" label-align="right">
<el-tag :type="getFileTypeColor(fileDetail.type, fileDetail.url, fileDetail.realName)">{{ fileDetail.type || '未知' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item>
<el-descriptions-item label="是否公开" label-align="right">
<el-tag :type="fileDetail.openDow ? 'success' : 'info'">
{{ fileDetail.openDow ? '公开访问' : '私有' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="保存路径" label-align="right" :span="2">
<span class="file-path">{{ fileDetail.savePath }}</span>
</el-descriptions-item>
<el-descriptions-item label="文件URL" label-align="right" :span="2">
<el-link :href="fileDetail.url" target="_blank" type="primary" v-if="fileDetail.url">
点击查看文件
</el-link>
<span v-else style="color: #909399;">无URL</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间" label-align="right">{{ formatDate(fileDetail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" label-align="right">{{ formatDate(fileDetail.UpdatedAt) }}</el-descriptions-item>
<el-descriptions-item label="备注" label-align="right" :span="2">{{ fileDetail.content || '无' }}</el-descriptions-item>
</el-descriptions>
<!-- 详细信息网格 -->
<div class="detail-grid">
<div class="detail-grid-item">
<span class="grid-label">保存名称</span>
<span class="grid-value mono">{{ fileDetail.saveName }}</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">保存路径</span>
<span class="grid-value mono">{{ fileDetail.savePath }}</span>
</div>
<div class="detail-grid-item" v-if="fileDetail.url">
<span class="grid-label">访问地址</span>
<div class="grid-value url-row">
<el-link :href="fileDetail.url" target="_blank" type="primary" class="url-text">{{ fileDetail.url }}</el-link>
<el-icon class="url-copy-icon" @click="copyPath(fileDetail.url)" title="复制地址"><DocumentCopy /></el-icon>
</div>
</div>
<div class="detail-grid-item" v-if="fileDetail.UpdatedAt && fileDetail.UpdatedAt !== fileDetail.CreatedAt">
<span class="grid-label">更新时间</span>
<span class="grid-value">{{ formatDate(fileDetail.UpdatedAt) }}</span>
</div>
<div class="detail-grid-item" v-if="fileDetail.content">
<span class="grid-label">备注</span>
<span class="grid-value">{{ fileDetail.content }}</span>
</div>
</div>
</div>
</el-dialog>
@@ -255,13 +290,14 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Delete, Search, Document, VideoPlay, Folder, UploadFilled, Picture, Refresh } from '@element-plus/icons-vue'
import { Upload, Delete, Search, Document, VideoPlay, Folder, UploadFilled, Picture, Refresh, View, Download, DocumentCopy } from '@element-plus/icons-vue'
import { getFileList, getFileDetail, updateFile, deleteFile, uploadFile } from '@/api/admin/file'
// 查询参数
const queryParams = reactive({
key: '',
user_id: undefined,
is_admin: undefined,
page: 1,
count: 10
})
@@ -391,6 +427,7 @@ const handleQuery = () => {
const resetQuery = () => {
queryParams.key = ''
queryParams.user_id = undefined
queryParams.is_admin = undefined
queryParams.page = 1
fetchFileList()
}
@@ -676,6 +713,20 @@ const submitEditForm = () => {
})
}
// 在新标签页打开文件
const openFileUrl = (url) => {
window.open(url, '_blank')
}
// 复制路径
const copyPath = (text) => {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
}).catch(() => {
ElMessage.info(text)
})
}
// 初始化
onMounted(() => {
fetchFileList()
@@ -750,28 +801,43 @@ onMounted(() => {
}
.file-detail-container {
padding: 10px 0;
padding: 0;
}
.file-preview-section {
margin-bottom: 24px;
}
.preview-label {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
}
.preview-content {
/* 顶部区域:预览 + 摘要 */
.detail-top {
display: flex;
gap: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f4;
margin-bottom: 16px;
}
.detail-preview {
flex-shrink: 0;
width: 200px;
min-height: 160px;
max-height: 300px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f5f7fa;
border-radius: 8px;
padding: 20px;
min-height: 200px;
justify-content: center;
background: #f8f9fb;
border-radius: 10px;
overflow: hidden;
padding: 8px;
}
.preview-image {
width: 100%;
height: 100%;
max-height: 280px;
border-radius: 6px;
}
.preview-image :deep(img) {
object-fit: contain;
width: 100%;
height: 100%;
}
.file-icon-large {
@@ -780,12 +846,6 @@ onMounted(() => {
align-items: center;
justify-content: center;
color: #909399;
gap: 12px;
}
.file-type-text {
font-size: 14px;
color: #606266;
}
.image-error {
@@ -793,23 +853,119 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
color: #c0c4cc;
gap: 6px;
font-size: 12px;
}
.detail-summary {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.detail-filename {
font-size: 16px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-meta-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.file-info-descriptions {
margin-top: 16px;
.detail-meta-row.secondary {
font-size: 12px;
color: #909399;
gap: 14px;
}
.file-path {
font-family: 'Courier New', monospace;
.detail-size {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.detail-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* 详细信息网格 */
.detail-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-grid-item {
display: flex;
align-items: baseline;
gap: 12px;
}
.grid-label {
flex-shrink: 0;
width: 70px;
font-size: 12px;
color: #909399;
text-align: right;
}
.grid-value {
font-size: 13px;
color: #303133;
word-break: break-all;
line-height: 1.5;
}
.grid-value.mono {
font-family: 'SF Mono', 'Menlo', 'Courier New', monospace;
font-size: 12px;
color: #606266;
word-break: break-all;
}
:deep(.el-descriptions__label) {
width: 120px;
.url-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 420px;
}
.url-copy-icon {
flex-shrink: 0;
cursor: pointer;
color: #909399;
transition: color 0.2s;
}
.url-copy-icon:hover {
color: #409eff;
}
:deep(.file-detail-dialog .el-dialog__header) {
padding: 16px 20px 0;
}
:deep(.file-detail-dialog .el-dialog__body) {
padding: 20px;
}
/* 表格样式优化 */
+617 -101
View File
@@ -52,14 +52,22 @@
</el-button>
</div>
</el-popover>
<div class="ticket-title" v-if="ticketInfo">{{ ticketInfo.title }}</div>
<div class="ticket-title" v-if="ticketInfo">
<span class="weight-indicator" :class="'weight-' + ticketInfo.weight" :title="getWeightLabel(ticketInfo.weight)"></span>
{{ ticketInfo.title }}
</div>
<el-tag v-if="ticketInfo?.workOrderType?.name" size="small" type="info" class="type-tag">{{ ticketInfo.workOrderType.name }}</el-tag>
</div>
<div class="header-right" v-if="ticketInfo">
<span class="ticket-id">工单号: {{ ticketInfo.id }}</span>
<span class="ticket-id-badge" @click="copyTicketId" title="点击复制工单号">
#{{ ticketInfo.id }}
<el-icon :size="12"><DocumentCopy /></el-icon>
</span>
<el-select
v-model="ticketInfo.status"
size="small"
style="width: 120px"
:class="'status-select-' + ticketInfo.status"
@change="handleStatusChange"
>
<el-option label="待处理" value="pending" />
@@ -78,46 +86,86 @@
</div>
</div>
<!-- 关联商品信息 -->
<div class="goods-info-panel" v-if="ticketInfo?.userGoods">
<div class="goods-panel-header">
<el-icon :size="14"><Box /></el-icon>
<span class="goods-panel-title">关联商品</span>
<span class="gp-link gp-jump" @click="goToUserGoods">查看详情 </span>
</div>
<div class="goods-panel-body">
<div class="goods-panel-item" v-if="ticketInfo.userGoods.good?.name || ticketInfo.userGoods.tag">
<span class="gp-label">商品</span>
<span class="gp-value">{{ ticketInfo.userGoods.good?.name || ticketInfo.userGoods.tag }}</span>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.tag">
<span class="gp-label">分类</span>
<el-tag size="small" type="info">{{ ticketInfo.userGoods.tag }}</el-tag>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.itemArg?.name">
<span class="gp-label">实例</span>
<span class="gp-value">{{ ticketInfo.userGoods.itemArg.name }}</span>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.itemArg?.status">
<span class="gp-label">状态</span>
<span class="gp-status-indicator" :class="ticketInfo.userGoods.itemArg.status === 'running' ? 'running' : 'other'"></span>
<el-tag :type="ticketInfo.userGoods.itemArg.status === 'running' ? 'success' : 'warning'" size="small">{{ ticketInfo.userGoods.itemArg.status }}</el-tag>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.itemArg?.ips">
<span class="gp-label">IP</span>
<span class="gp-value gp-ips gp-copyable" @click.stop="copyText(ticketInfo.userGoods.itemArg.ips)" title="点击复制">{{ ticketInfo.userGoods.itemArg.ips }}</span>
</div>
<div class="goods-panel-item" v-if="ticketInfo.userGoods.expireTime">
<span class="gp-label">到期</span>
<span class="gp-value">{{ formatGoodsTime(ticketInfo.userGoods.expireTime) }}</span>
</div>
</div>
</div>
<!-- 聊天记录 -->
<div class="chat-container" v-loading="isLoadingMessages">
<div class="chat-messages" ref="messagesContainer">
<div
v-for="(message, index) in messages"
:key="index"
:class="['message-item', message.isAdmin ? 'message-admin' : 'message-user']"
>
<div class="message-avatar" v-if="!message.isAdmin">
<el-avatar :size="36" :src="message.avatar">{{ ticketInfo?.username?.charAt(0) || 'U' }}</el-avatar>
<template v-for="(message, index) in messages" :key="index">
<div class="time-separator" v-if="showTimeSeparator(index)">
<span class="time-separator-text">{{ formatMessageTime(message.time) }}</span>
</div>
<div class="message-content">
<div class="message-text" v-if="message.content">
{{ message.content }}
<span
v-if="message.isAdmin && !message.isTempMessage"
class="edit-btn"
@click="handleEditMessage(message)"
title="编辑消息"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</span>
<div :class="['message-item', message.isAdmin ? 'message-admin' : 'message-user']">
<div class="message-avatar" v-if="!message.isAdmin">
<el-avatar :size="36" :src="message.avatar">{{ ticketInfo?.username?.charAt(0) || 'U' }}</el-avatar>
</div>
<div class="message-images" v-if="message.images && message.images.length">
<img
v-for="(img, imgIndex) in message.images"
:key="imgIndex"
:src="img"
class="message-image"
@click="openImage(img)"
/>
<div class="message-content">
<div class="message-text" v-if="message.content">
{{ message.content }}
<span
v-if="message.isAdmin && !message.isTempMessage"
class="edit-btn"
@click="handleEditMessage(message)"
title="编辑消息"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</span>
</div>
<div class="message-images" v-if="message.images && message.images.length">
<img
v-for="(img, imgIndex) in message.images"
:key="imgIndex"
:src="img"
class="message-image"
@click="openImage(img)"
/>
</div>
<div class="message-time" v-if="!showTimeSeparator(index)">{{ formatMessageTime(message.time) }}</div>
</div>
<div class="message-avatar" v-if="message.isAdmin">
<el-avatar :size="36" :src="message.avatar">A</el-avatar>
</div>
<div class="message-time">{{ formatMessageTime(message.time) }}</div>
</div>
<div class="message-avatar" v-if="message.isAdmin">
<el-avatar :size="36" :src="message.avatar">A</el-avatar>
</div>
</div>
</template>
</div>
<div class="new-message-tip" v-if="hasNewMessages" @click="scrollToBottom">
有新消息
</div>
</div>
@@ -131,16 +179,32 @@
</div>
</div>
<!-- 快捷回复 -->
<div class="quick-replies">
<el-button
v-for="(reply, index) in quickReplies"
:key="index"
<!-- 回复模板 -->
<div class="reply-templates">
<el-input
v-model="templateKeyword"
placeholder="筛选模板"
size="small"
@click="useQuickReply(reply)"
clearable
class="template-filter-input"
@input="() => fetchTemplateList(true)"
/>
<el-tooltip
v-for="tpl in templateList"
:key="tpl.id"
:content="tpl.content?.slice(0, 80) + (tpl.content?.length > 80 ? '...' : '')"
placement="top"
:show-after="400"
>
{{ reply.title }}
</el-button>
<div class="template-tag" @click="useTemplate(tpl)">
<span class="template-tag-name">{{ tpl.name }}</span>
<el-icon class="template-tag-delete" @click.stop="handleTemplateDelete(tpl)"><Close /></el-icon>
</div>
</el-tooltip>
<div class="template-tag template-tag-add" @click="openAddTemplate">
<el-icon><Plus /></el-icon>
<span>新增模板</span>
</div>
</div>
<!-- 输入框 -->
@@ -151,14 +215,17 @@
@dragleave.prevent="handleDragLeave"
:class="{ 'drag-over': isDragOver }"
>
<el-input
ref="textareaRef"
v-model="messageInput"
type="textarea"
:rows="3"
placeholder="请输入回复内容(支持粘贴图片和拖拽图片)..."
@keyup.ctrl.enter="sendMessage"
/>
<div class="textarea-wrap">
<el-input
ref="textareaRef"
v-model="messageInput"
type="textarea"
:rows="3"
placeholder="请输入回复内容(支持粘贴图片和拖拽图片)..."
@keyup.ctrl.enter="sendMessage"
/>
<span class="char-count" v-if="messageInput.length > 0">{{ messageInput.length }}</span>
</div>
<div class="input-actions">
<div class="left-actions">
<el-upload
@@ -171,15 +238,18 @@
>
<el-button type="primary" plain icon="Plus">图片</el-button>
</el-upload>
<el-button plain @click="saveCurrentAsTemplate" :disabled="!messageInput.trim()">存为模板</el-button>
<el-button plain @click="loadMoreTemplates" :loading="templateLoadingMore" v-if="templateHasMore">加载更多</el-button>
</div>
<span class="hint-text">Ctrl + Enter 发送</span>
<el-button
type="primary"
:type="sendSuccess ? 'success' : 'primary'"
:disabled="!messageInput.trim() && selectedImages.length === 0"
:loading="isSending"
@click="sendMessage"
:class="{ 'send-success-anim': sendSuccess }"
>
发送
{{ sendSuccess ? '✓' : '发送' }}
</el-button>
</div>
</div>
@@ -190,6 +260,27 @@
<el-alert title="该工单已结束,无法继续回复" type="info" :closable="false" />
</div>
<!-- 模板编辑对话框 -->
<el-dialog
v-model="showTemplateDialog"
:title="isEditingTemplate ? '编辑模板' : '新增模板'"
width="480px"
destroy-on-close
>
<el-form label-width="80px">
<el-form-item label="模板名称">
<el-input v-model="templateForm.name" placeholder="请输入模板名称" maxlength="50" />
</el-form-item>
<el-form-item label="模板内容">
<el-input v-model="templateForm.content" type="textarea" :rows="5" placeholder="请输入模板内容" maxlength="1000" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTemplateDialog = false">取消</el-button>
<el-button type="primary" @click="handleTemplateSave">保存</el-button>
</template>
</el-dialog>
<!-- 编辑消息对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑消息" width="600px">
<el-form>
@@ -242,8 +333,8 @@
import { ref, onMounted, nextTick, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getTicketDetail, replyTicket, closeTicket, updateTicketInfo, updateTicketReplayInfo } from '@/api/ticket'
import { Plus } from '@element-plus/icons-vue'
import { getTicketDetail, replyTicket, closeTicket, updateTicketInfo, updateTicketReplayInfo, getReplyTemplateList, createReplyTemplate, updateReplyTemplate, deleteReplyTemplate } from '@/api/ticket'
import { Plus, Box, Search, Edit, Delete, Close, DocumentCopy } from '@element-plus/icons-vue'
import { getUserInfo } from '@/api/admin/user'
import { uploadFile } from '@/api/admin/file'
import { useUserStore } from '@/store/userStore'
@@ -259,6 +350,7 @@ const ticketInfo = ref(null)
const messages = ref([])
const isLoadingMessages = ref(false)
const isSending = ref(false)
const sendSuccess = ref(false)
// 用户详情弹窗
const userDetail = ref(null)
@@ -288,13 +380,116 @@ const isEditingSaving = ref(false)
// 定时刷新
const refreshTimer = ref(null)
// 快捷回复
const quickReplies = [
{ title: '您好,有什么可以帮助您的?', content: '您好,有什么可以帮助您的?' },
{ title: '正在处理中', content: '您的问题正在处理中,请稍等片刻,我们会尽快给您答复。' },
{ title: '需要更多信息', content: '为了更好地解决您的问题,请您提供更多相关信息。' },
{ title: '问题已解决', content: '您的问题已解决,感谢您的反馈。如有其他问题,请随时联系我们。' }
]
// 回复模板
const templateList = ref([])
const templateKeyword = ref('')
const templatePage = ref(1)
const templatePageSize = ref(10)
const templateTotal = ref(0)
const templateHasMore = ref(false)
const templateLoadingMore = ref(false)
const showTemplateDialog = ref(false)
const templateForm = ref({ id: null, name: '', content: '' })
const isEditingTemplate = ref(false)
const fetchTemplateList = async (reset = true) => {
try {
if (reset) templatePage.value = 1
const params = { count: templatePageSize.value, page: templatePage.value }
if (templateKeyword.value) params.keyword = templateKeyword.value
const res = await getReplyTemplateList(params)
if (res.code === 200) {
const list = res.data?.data || []
templateTotal.value = res.data?.all_count || 0
if (reset) {
templateList.value = list
} else {
templateList.value = [...templateList.value, ...list]
}
templateHasMore.value = templateList.value.length < templateTotal.value
}
} catch (e) {
console.error('获取模板列表失败', e)
}
}
const loadMoreTemplates = async () => {
templateLoadingMore.value = true
templatePage.value++
await fetchTemplateList(false)
templateLoadingMore.value = false
}
const useTemplate = (tpl) => {
messageInput.value = tpl.content
}
const openAddTemplate = () => {
isEditingTemplate.value = false
templateForm.value = { id: null, name: '', content: '' }
showTemplateDialog.value = true
}
const openEditTemplate = (tpl) => {
isEditingTemplate.value = true
templateForm.value = { id: tpl.id, name: tpl.name, content: tpl.content }
showTemplateDialog.value = true
}
const handleTemplateSave = async () => {
if (!templateForm.value.name.trim() || !templateForm.value.content.trim()) {
ElMessage.warning('模板名称和内容不能为空')
return
}
try {
if (isEditingTemplate.value) {
const res = await updateReplyTemplate(templateForm.value)
if (res.code === 200) {
ElMessage.success('模板已更新')
} else {
ElMessage.error(res.message || '更新失败')
return
}
} else {
const res = await createReplyTemplate({ name: templateForm.value.name, content: templateForm.value.content })
if (res.code === 200) {
ElMessage.success('模板已创建')
} else {
ElMessage.error(res.message || '创建失败')
return
}
}
showTemplateDialog.value = false
fetchTemplateList()
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleTemplateDelete = async (tpl) => {
try {
await ElMessageBox.confirm(`确定删除模板「${tpl.name}」?`, '删除确认', { type: 'warning' })
const res = await deleteReplyTemplate({ id: tpl.id })
if (res.code === 200) {
ElMessage.success('已删除')
fetchTemplateList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (e) {
// 取消删除
}
}
const saveCurrentAsTemplate = () => {
if (!messageInput.value.trim()) {
ElMessage.warning('输入框内容为空,无法保存为模板')
return
}
isEditingTemplate.value = false
templateForm.value = { id: null, name: '', content: messageInput.value.trim() }
showTemplateDialog.value = true
}
// 状态转换
const convertStatusToString = (status) => {
@@ -370,7 +565,10 @@ const fetchTicketDetail = async (showLoading = true) => {
userId: detail.user?.userId,
avatar: detail.user?.coverUrl || '',
createTime: new Date(detail.created_at).toLocaleString(),
status: convertStatusToString(detail.status)
status: convertStatusToString(detail.status),
userGoods: detail.userGoods ? parseUserGoods(detail.userGoods) : null,
workOrderType: detail.workOrderType || null,
weight: detail.weight
}
// 处理消息列表并按ID排序
@@ -392,11 +590,19 @@ const fetchTicketDetail = async (showLoading = true) => {
const hasChanges = !messagesEqual(messages.value, newMessages)
if (hasChanges) {
const shouldScroll = showLoading || newMessages.length > messages.value.length
const isNewMsg = newMessages.length > messages.value.length
messages.value = newMessages
if (shouldScroll) {
if (showLoading) {
nextTick(() => scrollToBottom())
} else if (isNewMsg) {
const container = messagesContainer.value
const isAtBottom = container && (container.scrollHeight - container.scrollTop - container.clientHeight < 80)
if (isAtBottom) {
nextTick(() => scrollToBottom())
} else {
hasNewMessages.value = true
}
}
}
}
@@ -497,7 +703,8 @@ const sendMessage = async () => {
if (res.code === 200) {
messages.value = messages.value.filter(msg => !msg.isTempMessage)
await fetchTicketDetail()
ElMessage.success('回复成功')
sendSuccess.value = true
setTimeout(() => { sendSuccess.value = false }, 1500)
} else {
messages.value = messages.value.filter(msg => !msg.isTempMessage)
messageInput.value = inputMsg
@@ -790,15 +997,22 @@ const saveEditMessage = async () => {
}
}
// 快捷回复
const useQuickReply = (reply) => {
messageInput.value = reply.content
}
// 滚动到底部
const hasNewMessages = ref(false)
const showTimeSeparator = (index) => {
if (index === 0) return true
const curr = new Date(messages.value[index]?.time)
const prev = new Date(messages.value[index - 1]?.time)
if (isNaN(curr.getTime()) || isNaN(prev.getTime())) return false
return (curr.getTime() - prev.getTime()) > 3600000
}
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
hasNewMessages.value = false
}
}
@@ -863,11 +1077,32 @@ const formatMessageTime = (timeStr) => {
// 返回列表
const goBack = () => {
// 关闭当前tab
tagsViewStore.delVisitedView(route)
router.push('/ticket/list')
}
const getWeightLabel = (weight) => {
const map = { 0: '常规', 1: '一般', 2: '紧急', 3: '非常紧急' }
return map[weight] || '常规'
}
const copyTicketId = () => {
const id = `#${ticketInfo.value.id}`
navigator.clipboard.writeText(id).then(() => {
ElMessage.success(`已复制 ${id}`)
}).catch(() => {
ElMessage.info(id)
})
}
const copyText = (text) => {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制')
}).catch(() => {
ElMessage.info(text)
})
}
// 获取用户详情
const fetchUserDetail = async () => {
if (!ticketInfo.value?.userId) return
@@ -887,10 +1122,40 @@ const fetchUserDetail = async () => {
// 跳转用户详情
const goToUserDetail = () => {
if (ticketInfo.value?.userId) {
router.push({ path: '/user/detail', query: { user_id: ticketInfo.value.userId } })
const href = router.resolve({ path: '/user/detail', query: { user_id: ticketInfo.value.userId } }).href
window.open(href, '_blank')
}
}
const goToUserGoods = () => {
const goods = ticketInfo.value?.userGoods
if (!goods) return
const tag = (goods.tag || goods.good?.tag || '').toLowerCase()
let href
if (tag === '云服务器') {
href = router.resolve({ path: '/user-goods/vm-detail', query: { id: goods.id } }).href
} else {
href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
}
window.open(href, '_blank')
}
const parseUserGoods = (raw) => {
const goods = { ...raw }
// itemArg 可能是 JSON 字符串
if (typeof goods.itemArg === 'string') {
try { goods.itemArg = JSON.parse(goods.itemArg) } catch { goods.itemArg = null }
}
return goods
}
const formatGoodsTime = (time) => {
if (!time) return '—'
const d = new Date(time)
if (isNaN(d.getTime())) return '—'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
// 定时刷新
const startAutoRefresh = () => {
refreshTimer.value = setInterval(() => {
@@ -919,17 +1184,24 @@ watch(
}
)
// ticketInfo 加载后 textarea 才渲染(v-if),此时绑定粘贴事件
let pasteBindDone = false
watch(ticketInfo, (val) => {
if (val && !pasteBindDone) {
pasteBindDone = true
nextTick(() => {
const textarea = textareaRef.value?.$el?.querySelector('textarea')
if (textarea) {
textarea.addEventListener('paste', handlePaste)
}
})
}
})
onMounted(() => {
fetchTicketDetail()
fetchTemplateList()
startAutoRefresh()
// 绑定粘贴事件到原生 textarea
nextTick(() => {
const textarea = textareaRef.value?.$el?.querySelector('textarea')
if (textarea) {
textarea.addEventListener('paste', handlePaste)
}
})
})
onBeforeUnmount(() => {
@@ -948,7 +1220,7 @@ onBeforeUnmount(() => {
padding: 0;
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
height: calc(100vh - 148px);
min-height: 600px;
}
@@ -961,6 +1233,70 @@ onBeforeUnmount(() => {
border-bottom: 1px solid #ebeef5;
}
.goods-info-panel {
background: #f8fbff;
border-bottom: 1px solid #e6f2ff;
padding: 10px 20px;
}
.goods-panel-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
font-weight: 500;
}
.goods-panel-title {
color: #303133;
flex: 1;
}
.goods-panel-header .gp-jump {
font-size: 12px;
font-weight: 400;
}
.goods-panel-body {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
.goods-panel-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.goods-panel-item .gp-label {
color: #909399;
}
.goods-panel-item .gp-value {
color: #303133;
}
.goods-panel-item .gp-link {
color: #409eff;
cursor: pointer;
font-weight: 500;
}
.goods-panel-item .gp-link:hover,
.goods-panel-header .gp-link:hover {
text-decoration: underline;
}
.goods-panel-item .gp-ips {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.header-left {
display: flex;
align-items: center;
@@ -973,7 +1309,7 @@ onBeforeUnmount(() => {
gap: 12px;
}
.ticket-id {
.ticket-id, .ticket-id-badge {
font-weight: 500;
color: #303133;
font-size: 14px;
@@ -1050,6 +1386,9 @@ onBeforeUnmount(() => {
}
.ticket-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 15px;
font-weight: 500;
color: #303133;
@@ -1059,10 +1398,145 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.weight-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.weight-indicator.weight-0 { background: #c0c4cc; }
.weight-indicator.weight-1 { background: #409eff; }
.weight-indicator.weight-2 { background: #e6a23c; }
.weight-indicator.weight-3 { background: #f56c6c; animation: pulse-weight 1.5s infinite; }
@keyframes pulse-weight {
0% { box-shadow: 0 0 0 0 rgba(245,108,108,0.5); }
70% { box-shadow: 0 0 0 5px rgba(245,108,108,0); }
100% { box-shadow: 0 0 0 0 rgba(245,108,108,0); }
}
.type-tag {
margin-left: 6px;
flex-shrink: 0;
}
.ticket-id-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: #f4f4f5;
border-radius: 4px;
font-size: 13px;
font-family: 'SF Mono', 'Menlo', monospace;
color: #606266;
cursor: pointer;
transition: all 0.2s;
}
.ticket-id-badge:hover {
background: #ecf5ff;
color: #409eff;
}
.status-select-pending :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #e6a23c inset; }
.status-select-processing :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #409eff inset; }
.status-select-replied :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #909399 inset; }
.status-select-completed :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #67c23a inset; }
/* 商品面板增强 */
.gp-status-indicator {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.gp-status-indicator.running {
background: #67c23a;
animation: pulse-weight 1.5s infinite;
}
.gp-status-indicator.other {
background: #c0c4cc;
}
.gp-copyable {
cursor: pointer;
transition: color 0.2s;
}
.gp-copyable:hover {
color: #409eff;
}
/* 聊天区增强 */
.time-separator {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
}
.time-separator-text {
font-size: 12px;
color: #909399;
background: #ebeef5;
padding: 2px 12px;
border-radius: 10px;
}
.new-message-tip {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
padding: 6px 16px;
background: #409eff;
color: #fff;
border-radius: 16px;
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 12px rgba(64,158,255,0.3);
transition: all 0.2s;
z-index: 10;
}
.new-message-tip:hover {
background: #337ecc;
}
/* 输入框增强 */
.textarea-wrap {
position: relative;
}
.char-count {
position: absolute;
bottom: 6px;
right: 12px;
font-size: 11px;
color: #c0c4cc;
pointer-events: none;
}
.send-success-anim {
animation: send-pop 0.3s ease;
}
@keyframes send-pop {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.chat-container {
position: relative;
flex: 1;
min-height: 400px;
background: #f5f7fa;
background: #f8f9fb;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -1095,10 +1569,11 @@ onBeforeUnmount(() => {
.message-text {
background: #fff;
padding: 12px 16px;
border-radius: 8px;
border-radius: 12px 12px 12px 4px;
word-break: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
position: relative;
line-height: 1.5;
}
.message-text .edit-btn {
@@ -1124,8 +1599,9 @@ onBeforeUnmount(() => {
}
.message-admin .message-text {
background: #409eff;
background: linear-gradient(135deg, #409eff, #337ecc);
color: #fff;
border-radius: 12px 12px 4px 12px;
}
.message-admin .message-text .edit-btn {
@@ -1204,11 +1680,64 @@ onBeforeUnmount(() => {
font-size: 14px;
}
.quick-replies {
.reply-templates {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
margin-bottom: 10px;
}
.template-filter-input {
width: 130px;
}
.template-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
border-radius: 4px;
border: 1px solid var(--el-border-color);
background: var(--el-fill-color-blank);
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.template-tag:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.template-tag-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-tag-delete {
font-size: 12px;
color: var(--el-text-color-placeholder);
border-radius: 50%;
transition: all 0.2s;
}
.template-tag-delete:hover {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
.template-tag-add {
border-style: dashed;
color: var(--el-text-color-secondary);
}
.template-tag-add:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.input-area {
@@ -1350,10 +1879,6 @@ onBeforeUnmount(() => {
max-width: 70%;
}
.quick-replies {
flex-wrap: wrap;
}
.input-area {
gap: 10px;
}
@@ -1445,15 +1970,6 @@ onBeforeUnmount(() => {
padding: 12px;
}
.quick-replies {
gap: 6px;
}
.quick-replies .el-button {
font-size: 12px;
padding: 6px 10px;
}
.input-actions {
flex-direction: column;
gap: 12px;
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
<template>
<div class="ticket-templates-page">
<div class="page-header">
<h3>回复模板管理</h3>
<div class="header-actions">
<el-input
v-model="keyword"
placeholder="搜索模板名称或内容"
clearable
:prefix-icon="Search"
style="width: 240px"
@clear="loadList"
@keyup.enter="loadList"
/>
<el-button type="primary" :icon="Plus" @click="openAdd">新增模板</el-button>
</div>
</div>
<el-table :data="templateList" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="模板名称" min-width="160" />
<el-table-column prop="content" label="模板内容" min-width="300" show-overflow-tooltip />
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.UpdatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="currentPage"
@current-change="handlePageChange"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑模板' : '新增模板'"
width="560px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入模板名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="请输入模板内容" maxlength="2000" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getReplyTemplateList, createReplyTemplate, updateReplyTemplate, deleteReplyTemplate } from '@/api/ticket.js'
const loading = ref(false)
const templateList = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref(null)
const keyword = ref('')
const currentPage = ref(1)
const pageSize = ref(15)
const total = ref(0)
const form = ref({
id: null,
name: '',
content: ''
})
const rules = {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
const formatTime = (time) => {
if (!time) return '—'
const d = new Date(time)
if (isNaN(d.getTime())) return '—'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const loadList = async () => {
loading.value = true
try {
const params = { count: pageSize.value, page: currentPage.value }
if (keyword.value.trim()) params.keyword = keyword.value.trim()
const res = await getReplyTemplateList(params)
if (res.code === 200) {
templateList.value = res.data?.data || []
total.value = res.data?.all_count || 0
}
} catch (e) {
console.error('加载模板列表失败', e)
}
loading.value = false
}
const handlePageChange = (page) => {
currentPage.value = page
loadList()
}
const openAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', content: '' }
dialogVisible.value = true
}
const openEdit = (row) => {
isEdit.value = true
form.value = { id: row.id, name: row.name, content: row.content }
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value.validate()
submitting.value = true
try {
let res
if (isEdit.value) {
res = await updateReplyTemplate({ id: form.value.id, name: form.value.name, content: form.value.content })
} else {
res = await createReplyTemplate({ name: form.value.name, content: form.value.content })
}
if (res.code === 200) {
ElMessage.success(isEdit.value ? '修改成功' : '添加成功')
dialogVisible.value = false
loadList()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
submitting.value = false
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除模板「${row.name}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
}).then(async () => {
try {
const res = await deleteReplyTemplate({ id: row.id })
if (res.code === 200) {
ElMessage.success('删除成功')
loadList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
onMounted(() => { loadList() })
</script>
<style scoped>
.ticket-templates-page {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #2c3e50;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+194
View File
@@ -0,0 +1,194 @@
<template>
<div class="ticket-types-page">
<div class="page-header">
<h3>工单类型管理</h3>
<el-button type="primary" :icon="Plus" @click="openAdd">新增类型</el-button>
</div>
<el-table :data="typeList" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="类型名称" min-width="160" />
<el-table-column prop="note" label="说明" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.note || '—' }}
</template>
</el-table-column>
<el-table-column label="图标" width="80" align="center">
<template #default="{ row }">
<el-avatar v-if="row.ico?.savePath" :size="32" :src="row.ico.savePath" shape="square" />
<span v-else class="no-icon"></span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑工单类型' : '新增工单类型'"
width="480px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="说明" prop="note">
<el-input v-model="form.note" type="textarea" :rows="3" placeholder="请输入说明(选填)" />
</el-form-item>
<el-form-item label="图标ID" prop="icoId">
<el-input-number v-model="form.icoId" :min="0" placeholder="文件ID" controls-position="right" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getTicketTypeList, addTicketType, updateTicketType, deleteTicketType } from '@/api/ticket.js'
const loading = ref(false)
const typeList = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const formRef = ref(null)
const form = ref({
id: null,
name: '',
note: '',
icoId: 0
})
const rules = {
name: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const formatTime = (time) => {
if (!time) return '—'
const d = new Date(time)
if (isNaN(d.getTime())) return '—'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const loadList = async () => {
loading.value = true
try {
const res = await getTicketTypeList()
if (res?.code === 200) {
typeList.value = res.data || []
}
} catch (e) { /* ignore */ }
loading.value = false
}
const openAdd = () => {
isEdit.value = false
form.value = { id: null, name: '', note: '', icoId: 0 }
dialogVisible.value = true
}
const openEdit = (row) => {
isEdit.value = true
form.value = {
id: row.id,
name: row.name,
note: row.note || '',
icoId: row.icoId || 0
}
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value.validate()
submitting.value = true
try {
const data = { name: form.value.name }
if (form.value.note) data.note = form.value.note
if (form.value.icoId) data.icoId = form.value.icoId
let res
if (isEdit.value) {
data.id = form.value.id
res = await updateTicketType(data)
} else {
res = await addTicketType(data)
}
if (res?.code === 200) {
ElMessage.success(isEdit.value ? '修改成功' : '添加成功')
dialogVisible.value = false
loadList()
} else {
ElMessage.error(res?.message || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
submitting.value = false
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除工单类型「${row.name}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
}).then(async () => {
try {
const res = await deleteTicketType({ id: row.id })
if (res?.code === 200) {
ElMessage.success('删除成功')
loadList()
} else {
ElMessage.error(res?.message || '删除失败')
}
} catch (e) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
onMounted(() => { loadList() })
</script>
<style scoped>
.ticket-types-page {
padding: 20px;
background: #fff;
border-radius: 4px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #2c3e50;
}
.no-icon {
color: #909399;
}
</style>
+23 -15
View File
@@ -1153,7 +1153,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
@@ -2612,23 +2612,31 @@ const disposeCharts = () => {
trafficHourlyChart?.dispose(); trafficHourlyChart = null
}
const reloadWithNewId = (newId) => {
if (!newId || newId === userGoodsId.value) return
userGoodsId.value = newId
userGoods.value = null
vm.value = null
vmNetworks.value = []
vmVolumes.value = []
inPortGroup.value = null
outPortGroup.value = null
isVmGoods.value = false
metricsData.value = null
disposeCharts()
loadDetail()
}
watch(() => route.query.id, (newVal) => {
if (route.path === '/user-goods/vm-detail') {
reloadWithNewId(parseInt(newVal) || 0)
}
})
onMounted(() => { loadDetail() })
onActivated(() => {
const newId = parseInt(route.query.id) || 0
if (newId && newId !== userGoodsId.value) {
userGoodsId.value = newId
userGoods.value = null
vm.value = null
vmNetworks.value = []
vmVolumes.value = []
inPortGroup.value = null
outPortGroup.value = null
isVmGoods.value = false
metricsData.value = null
disposeCharts()
loadDetail()
}
reloadWithNewId(parseInt(route.query.id) || 0)
})
onBeforeUnmount(() => { disposeCharts() })