feat(admin): 工单管理 UI 优化与回复模板、文件管理增强
工单列表与详情 UI/交互优化及新工单提醒;新增回复模板与工单类型管理;文件管理增加管理员筛选并优化详情展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+38
-8
@@ -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' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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('确定要退出登录吗?', '提示', {
|
||||
|
||||
@@ -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>
|
||||
@@ -12,6 +12,14 @@ export const menus = [
|
||||
{
|
||||
path: '/ticket/list',
|
||||
title: '工单列表'
|
||||
},
|
||||
{
|
||||
path: '/ticket/types',
|
||||
title: '工单类型'
|
||||
},
|
||||
{
|
||||
path: '/ticket/templates',
|
||||
title: '回复模板'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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: '回复模板管理'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
+551
-257
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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() })
|
||||
|
||||
Reference in New Issue
Block a user