Files
ApiServer-Web-admin_dashboa…/src/components/layout/GlobalSearch.vue
T
shiran c18622226e
Build and Deploy Vue3 / build (push) Failing after 48s
Build and Deploy Vue3 / deploy (push) Has been skipped
feat(admin): 工单管理 UI 优化与回复模板、文件管理增强
工单列表与详情 UI/交互优化及新工单提醒;新增回复模板与工单类型管理;文件管理增加管理员筛选并优化详情展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:28:11 +08:00

582 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>