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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shiran
2026-06-02 17:28:11 +08:00
parent 928d14aada
commit c18622226e
12 changed files with 2480 additions and 477 deletions
+3 -18
View File
@@ -45,12 +45,8 @@
<breadcrumb />
</div>
<div class="navbar-right">
<div class="navbar-item hidden-mobile">
<el-tooltip content="全屏" placement="bottom">
<el-button type="text" class="header-btn" @click="toggleFullScreen">
<el-icon :size="18"><full-screen /></el-icon>
</el-button>
</el-tooltip>
<div class="navbar-item">
<GlobalSearch />
</div>
<div class="navbar-item">
@@ -103,9 +99,9 @@ import { useRoute, useRouter } from 'vue-router'
import SidebarMenuItem from './SidebarMenuItem.vue'
import Breadcrumb from './Breadcrumb.vue'
import TagsView from './TagsView.vue'
import GlobalSearch from './GlobalSearch.vue'
import { menus as menuConfig } from '@/config/menus'
import {
FullScreen,
ArrowDown,
User,
Key,
@@ -165,17 +161,6 @@ const closeMobileMenu = () => {
isMobileMenuOpen.value = false
}
// 切换全屏
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
}
// 退出登录
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
+581
View File
@@ -0,0 +1,581 @@
<template>
<div class="global-search">
<el-tooltip content="全局搜索" placement="bottom">
<el-button type="text" class="header-btn" @click="openSearch">
<el-icon :size="18"><Search /></el-icon>
</el-button>
</el-tooltip>
<el-dialog
v-model="visible"
:show-close="false"
:append-to-body="true"
class="search-dialog"
width="680px"
top="12vh"
@opened="focusInput"
>
<div class="search-header">
<el-icon :size="20" class="search-prefix"><Search /></el-icon>
<input
ref="searchInput"
v-model="keyword"
class="search-input"
placeholder="搜索用户、订单、工单、用户商品..."
@keydown.enter="handleSearch"
@keydown.escape="visible = false"
/>
<span v-if="keyword" class="search-clear" @click="clearSearch">
<el-icon :size="16"><CircleClose /></el-icon>
</span>
<span class="search-shortcut">ESC</span>
</div>
<div v-if="hasSearched" class="search-body">
<el-tabs v-model="activeTab" class="search-tabs">
<el-tab-pane name="user">
<template #label>
<span class="tab-label">用户 <em v-if="results.user.total > 0">{{ results.user.total }}</em></span>
</template>
<div class="result-list" v-loading="results.user.loading">
<div v-if="results.user.list.length === 0 && !results.user.loading" class="empty-tip">未找到相关用户</div>
<div
v-for="item in results.user.list"
:key="item.user_id"
class="result-item"
@click="goToUser(item)"
>
<el-avatar :size="32" :src="item.cover || ''" class="result-avatar">
{{ (item.user_name || '')[0] }}
</el-avatar>
<div class="result-info">
<span class="result-title" v-html="highlight(item.user_name)"></span>
<span class="result-desc">ID: {{ item.user_id }} · {{ item.phone || item.email || '—' }}</span>
</div>
<el-icon class="result-arrow"><ArrowRight /></el-icon>
</div>
</div>
<div class="result-pagination" v-if="results.user.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.user.total" :page-size="pageSize" v-model:current-page="results.user.page" @current-change="(p) => { results.user.page = p; searchUsers(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="order">
<template #label>
<span class="tab-label">订单 <em v-if="results.order.total > 0">{{ results.order.total }}</em></span>
</template>
<div class="result-list" v-loading="results.order.loading">
<div v-if="results.order.list.length === 0 && !results.order.loading" class="empty-tip">未找到相关订单</div>
<div
v-for="item in results.order.list"
:key="item.id"
class="result-item"
@click="goToOrder(item)"
>
<div class="result-icon order-icon">
<el-icon :size="18"><Document /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.name || ('#' + item.id))"></span>
<span class="result-desc">用户ID: {{ item.userId }} · ¥{{ (item.price / 100).toFixed(2) }} · {{ item.type }}</span>
</div>
<el-tag size="small" :type="orderStatusType(item.state)">{{ orderStatusText(item.state) }}</el-tag>
</div>
</div>
<div class="result-pagination" v-if="results.order.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.order.total" :page-size="pageSize" v-model:current-page="results.order.page" @current-change="(p) => { results.order.page = p; searchOrders(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="ticket">
<template #label>
<span class="tab-label">工单 <em v-if="results.ticket.total > 0">{{ results.ticket.total }}</em></span>
</template>
<div class="result-list" v-loading="results.ticket.loading">
<div v-if="results.ticket.list.length === 0 && !results.ticket.loading" class="empty-tip">未找到相关工单</div>
<div
v-for="item in results.ticket.list"
:key="item.work_id"
class="result-item"
@click="goToTicket(item)"
>
<div class="result-icon ticket-icon">
<el-icon :size="18"><ChatDotSquare /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.name)"></span>
<span class="result-desc">{{ item.user?.userName || ('用户' + item.user?.userId) }} · {{ formatTime(item.created_at) }}</span>
</div>
<el-tag size="small" :type="ticketStatusType(item.status)">{{ ticketStatusText(item.status) }}</el-tag>
</div>
</div>
<div class="result-pagination" v-if="results.ticket.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.ticket.total" :page-size="pageSize" v-model:current-page="results.ticket.page" @current-change="(p) => { results.ticket.page = p; searchTickets(keyword.trim()) }" />
</div>
</el-tab-pane>
<el-tab-pane name="goods">
<template #label>
<span class="tab-label">用户商品 <em v-if="results.goods.total > 0">{{ results.goods.total }}</em></span>
</template>
<div class="result-list" v-loading="results.goods.loading">
<div v-if="results.goods.list.length === 0 && !results.goods.loading" class="empty-tip">未找到相关用户商品</div>
<div
v-for="item in results.goods.list"
:key="item.id"
class="result-item"
@click="goToGoods(item)"
>
<div class="result-icon goods-icon">
<el-icon :size="18"><Box /></el-icon>
</div>
<div class="result-info">
<span class="result-title" v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
</div>
<el-icon class="result-arrow"><ArrowRight /></el-icon>
</div>
</div>
<div class="result-pagination" v-if="results.goods.total > pageSize">
<el-pagination small layout="prev, pager, next" :total="results.goods.total" :page-size="pageSize" v-model:current-page="results.goods.page" @current-change="(p) => { results.goods.page = p; searchGoods(keyword.trim()) }" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<div v-else class="search-placeholder">
<el-icon :size="48" class="placeholder-icon"><Search /></el-icon>
<p>输入关键词后按回车搜索</p>
<div class="search-tips">
<span>支持搜索用户名/手机号订单号工单标题商品名称</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, CircleClose, ArrowRight, Document, ChatDotSquare, Box } from '@element-plus/icons-vue'
import { getUserList } from '@/api/admin/user.js'
import { getOrderList } from '@/api/admin/order.js'
import { getTickerList } from '@/api/ticket.js'
import { getUserGoodsList } from '@/api/admin/userVm.js'
const router = useRouter()
const visible = ref(false)
const keyword = ref('')
const activeTab = ref('user')
const hasSearched = ref(false)
const searchInput = ref(null)
const pageSize = 10
const results = reactive({
user: { list: [], total: 0, loading: false, page: 1 },
order: { list: [], total: 0, loading: false, page: 1 },
ticket: { list: [], total: 0, loading: false, page: 1 },
goods: { list: [], total: 0, loading: false, page: 1 }
})
const openSearch = () => {
visible.value = true
}
const focusInput = () => {
searchInput.value?.focus()
}
const clearSearch = () => {
keyword.value = ''
hasSearched.value = false
searchInput.value?.focus()
}
const handleSearch = () => {
const key = keyword.value.trim()
if (!key) return
hasSearched.value = true
results.user.page = 1
results.order.page = 1
results.ticket.page = 1
results.goods.page = 1
searchUsers(key)
searchOrders(key)
searchTickets(key)
searchGoods(key)
}
const searchUsers = async (key) => {
results.user.loading = true
results.user.list = []
try {
const res = await getUserList({ page: results.user.page, count: pageSize, key })
if (res.data?.code === 200) {
results.user.list = res.data.data?.data || []
results.user.total = res.data.data?.all_count || results.user.list.length
}
} catch (e) { /* ignore */ }
results.user.loading = false
}
const searchOrders = async (key) => {
results.order.loading = true
results.order.list = []
try {
const res = await getOrderList({ page: results.order.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.order.list = res.data.data?.list || []
results.order.total = res.data.data?.all_count || results.order.list.length
}
} catch (e) { /* ignore */ }
results.order.loading = false
}
const searchTickets = async (key) => {
results.ticket.loading = true
results.ticket.list = []
try {
const res = await getTickerList(pageSize, results.ticket.page, '', '', '', '', key)
if (res?.code === 200) {
results.ticket.list = res.data?.data || []
results.ticket.total = res.data?.all_count || results.ticket.list.length
}
} catch (e) { /* ignore */ }
results.ticket.loading = false
}
const searchGoods = async (key) => {
results.goods.loading = true
results.goods.list = []
try {
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.goods.list = res.data.data?.data || []
results.goods.total = res.data.data?.all_count || results.goods.list.length
}
} catch (e) { /* ignore */ }
results.goods.loading = false
}
const highlight = (text) => {
if (!text || !keyword.value) return text
const key = keyword.value.trim()
if (!key) return text
const regex = new RegExp(`(${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return String(text).replace(regex, '<mark>$1</mark>')
}
const formatTime = (time) => {
if (!time) return '—'
const d = new Date(time)
if (isNaN(d.getTime())) return '—'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const goToUser = (item) => {
visible.value = false
router.push({ path: '/user/detail', query: { user_id: item.user_id } })
}
const goToOrder = (item) => {
visible.value = false
router.push({ path: '/order/list', query: { keyword: keyword.value } })
}
const goToTicket = (item) => {
visible.value = false
router.push({ path: '/ticket/detail', query: { id: item.work_id } })
}
const goToGoods = (item) => {
visible.value = false
const tag = (item.tag || item.good?.tag || '').toLowerCase()
if (tag === '云服务器') {
router.push({ path: '/user-goods/vm-detail', query: { id: item.id } })
} else {
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
}
}
const orderStatusText = (status) => {
const map = { 0: '待支付', 1: '已完成', 2: '已取消', 3: '已退款' }
return map[status] || '未知'
}
const orderStatusType = (status) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger' }
return map[status] || 'info'
}
const ticketStatusText = (status) => {
const map = { 0: '待处理', 1: '处理中', 2: '已回复', 3: '已关闭' }
return map[status] || '未知'
}
const ticketStatusType = (status) => {
const map = { 0: 'danger', 1: 'warning', 2: 'success', 3: 'info' }
return map[status] || 'info'
}
const handleKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
openSearch()
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped>
.global-search {
display: flex;
align-items: center;
}
.header-btn {
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
color: #34495e;
transition: all 0.2s ease;
border-radius: 0;
}
.header-btn:hover {
background-color: #f8f9fa;
color: #2c3e50;
}
</style>
<style>
.search-dialog .el-dialog__header {
display: none;
}
.search-dialog .el-dialog__body {
padding: 0;
}
.search-dialog .el-dialog {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.search-header {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e8ecf0;
gap: 12px;
}
.search-prefix {
color: #909399;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 16px;
color: #2c3e50;
background: transparent;
line-height: 1.5;
}
.search-input::placeholder {
color: #a8abb2;
}
.search-clear {
cursor: pointer;
color: #909399;
display: flex;
align-items: center;
transition: color 0.2s;
}
.search-clear:hover {
color: #606266;
}
.search-shortcut {
font-size: 12px;
color: #a8abb2;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 2px 6px;
flex-shrink: 0;
}
.search-body {
max-height: 460px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-tabs {
height: 100%;
}
.search-tabs .el-tabs__header {
padding: 0 20px;
margin-bottom: 0;
}
.search-tabs .el-tabs__content {
max-height: 400px;
overflow-y: auto;
}
.tab-label em {
font-style: normal;
font-size: 11px;
background: #409eff;
color: #fff;
border-radius: 8px;
padding: 1px 6px;
margin-left: 4px;
vertical-align: middle;
}
.result-list {
padding: 8px 12px;
min-height: 80px;
}
.result-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
gap: 12px;
}
.result-item:hover {
background: #f5f7fa;
}
.result-avatar {
flex-shrink: 0;
background: #ecf5ff;
color: #409eff;
font-size: 14px;
}
.result-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.order-icon {
background: #fdf6ec;
color: #e6a23c;
}
.ticket-icon {
background: #f0f9eb;
color: #67c23a;
}
.goods-icon {
background: #ecf5ff;
color: #409eff;
}
.result-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.result-title {
font-size: 14px;
color: #2c3e50;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-title :deep(mark) {
background: #fff3cd;
color: #e6a23c;
padding: 0 2px;
border-radius: 2px;
}
.result-desc {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-arrow {
color: #c0c4cc;
flex-shrink: 0;
}
.result-pagination {
display: flex;
justify-content: center;
padding: 8px 12px 12px;
border-top: 1px solid #f0f2f5;
}
.empty-tip {
text-align: center;
color: #909399;
font-size: 14px;
padding: 32px 0;
}
.search-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: #909399;
}
.placeholder-icon {
color: #dcdfe6;
margin-bottom: 12px;
}
.search-placeholder p {
margin: 0 0 8px;
font-size: 14px;
}
.search-tips {
font-size: 12px;
color: #a8abb2;
}
</style>