feat(admin): 工单管理 UI 优化与回复模板、文件管理增强
工单列表与详情 UI/交互优化及新工单提醒;新增回复模板与工单类型管理;文件管理增加管理员筛选并优化详情展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user