feat(admin): 补齐工单工作台与流量日志

---
 feat(admin): add ticket workspace and traffic logs
This commit is contained in:
yinjianm
2026-04-21 23:13:00 +08:00
parent 97cf167090
commit 60b5c99e74
12 changed files with 1614 additions and 55 deletions
+92
View File
@@ -11,11 +11,13 @@
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.15.1",
"element-plus": "^2.13.7",
"markdown-it": "^14.1.1",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
@@ -843,6 +845,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
@@ -858,6 +867,24 @@
"@types/lodash": "*"
}
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
@@ -1170,6 +1197,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -1955,6 +1988,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -2019,6 +2061,35 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2028,6 +2099,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -2245,6 +2322,15 @@
"node": ">=10"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -2459,6 +2545,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
+2
View File
@@ -12,11 +12,13 @@
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.15.1",
"element-plus": "^2.13.7",
"markdown-it": "^14.1.1",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
+44
View File
@@ -2,6 +2,10 @@ import { adminClient } from './client'
import type {
AdminPaginationResult,
AdminPlanOption,
AdminTicketDetail,
AdminTicketFetchParams,
AdminTicketListItem,
AdminTrafficLogResult,
AdminUserFetchParams,
AdminUserGeneratePayload,
AdminUserListItem,
@@ -114,3 +118,43 @@ export function resetUserSecret(id: number): Promise<ApiResponse<boolean>> {
export function deleteUser(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/destroy', { id })
}
export function fetchTickets(params: AdminTicketFetchParams): Promise<AdminPaginationResult<AdminTicketListItem>> {
return adminClient
.get<AdminPaginationResult<AdminTicketListItem>>('/ticket/fetch', { params })
.then((res) => res.data)
}
export function getTicketById(id: number): Promise<ApiResponse<AdminTicketDetail>> {
return unwrap<AdminTicketDetail>('/ticket/fetch', { id })
}
export function replyTicket(id: number, message: string): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/ticket/reply', { id, message })
}
export function closeTicket(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/ticket/close', { id })
}
export function fetchUserTrafficLogs(params: {
userId: number
pageSize?: number
page?: number
minTotal?: number
startTime?: number
endTime?: number
}): Promise<AdminTrafficLogResult> {
return adminClient
.get<AdminTrafficLogResult>('/stat/getStatUser', {
params: {
user_id: params.userId,
pageSize: params.pageSize,
page: params.page,
min_total: params.minTotal,
start_time: params.startTime,
end_time: params.endTime,
},
})
.then((res) => res.data)
}
+63
View File
@@ -221,6 +221,69 @@ export interface AdminUserUpdatePayload {
invite_user_email?: string | null
}
export interface AdminTicketMessage {
id: number
ticket_id: number
user_id: number
message: string
created_at: number
updated_at: number
is_from_user: boolean
is_from_admin: boolean
}
export interface AdminTicketListItem {
id: number
user_id: number
subject: string
level: string | number | null
status: number
reply_status: number | null
last_reply_user_id: number | null
created_at: number
updated_at: number
user: AdminUserListItem
}
export interface AdminTicketDetail extends AdminTicketListItem {
messages: AdminTicketMessage[]
}
export interface AdminTicketFetchParams {
current?: number
pageSize?: number
status?: number
reply_status?: number[]
email?: string
filter?: AdminUserFilter[]
sort?: AdminUserSort[]
}
export interface AdminTrafficLogItem {
id: number
user_id?: number
d: number
u: number
record_at: number
display_at: number
record_type: string | null
server_rate: number
server_id: number | null
server_type: string | null
server_name: string | null
node_name: string | null
node_key: string | null
device_name: string
device_ips: string[]
device_count: number
created_at: number | string | null
updated_at: number | string | null
}
export interface AdminTrafficLogResult extends AdminPaginationResult<AdminTrafficLogItem> {
summary: TrafficAmount
}
declare global {
interface Window {
settings?: {
+3
View File
@@ -17,6 +17,7 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@@ -33,12 +34,14 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+101
View File
@@ -0,0 +1,101 @@
import MarkdownIt from 'markdown-it'
import type {
AdminTicketFetchParams,
AdminTicketListItem,
AdminTrafficLogItem,
AdminUserFilter,
} from '@/types/api'
export interface TicketMeta {
label: string
type: 'primary' | 'success' | 'warning' | 'danger' | 'info'
}
const markdown = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
})
function normalizeTicketLevelValue(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') {
return null
}
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
export function getTicketLevelMeta(level: string | number | null | undefined): TicketMeta {
const numeric = normalizeTicketLevelValue(level)
if (numeric === 2) {
return { label: '高优先', type: 'danger' }
}
if (numeric === 1) {
return { label: '中优先', type: 'warning' }
}
if (numeric === 0) {
return { label: '低优先', type: 'info' }
}
if (typeof level === 'string' && level.trim()) {
return { label: level.trim(), type: 'info' }
}
return { label: '未设置', type: 'info' }
}
export function getTicketStatusMeta(ticket: Pick<AdminTicketListItem, 'status' | 'reply_status'>): TicketMeta {
if (ticket.status === 1) {
return { label: '已关闭', type: 'info' }
}
if (ticket.reply_status === 0) {
return { label: '待回复', type: 'danger' }
}
return { label: '处理中', type: 'success' }
}
export function renderTicketMarkdown(source: string): string {
return markdown.render(source || '')
}
export function buildTicketFilters(keyword: string, levelFilter: string): Pick<AdminTicketFetchParams, 'email' | 'filter'> {
const filters: AdminUserFilter[] = []
const normalized = keyword.trim()
let email: string | undefined
if (normalized) {
if (normalized.includes('@')) {
email = normalized
} else {
filters.push({ id: 'subject', value: normalized })
}
}
if (levelFilter !== 'all') {
filters.push({ id: 'level', value: [Number(levelFilter)] })
}
return {
email,
filter: filters.length ? filters : undefined,
}
}
export function getTrafficTotal(log: Pick<AdminTrafficLogItem, 'u' | 'd'>): number {
return (Number(log.u) || 0) + (Number(log.d) || 0)
}
export function formatServerRate(rate: number | null | undefined): string {
const numeric = Number(rate)
if (!Number.isFinite(numeric) || numeric <= 0) {
return '1x'
}
return `${numeric}x`
}
+43
View File
@@ -0,0 +1,43 @@
interface UploadResponse {
code: number
msg: string
data?: Array<{
copyUrl: string
}>
}
export async function uploadImage(file: File): Promise<string> {
const formData = new FormData()
formData.append('files', file)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/upload/rest/upload', true)
xhr.setRequestHeader('Accept', 'application/json')
xhr.onload = () => {
if (xhr.status !== 200) {
reject(new Error(`上传失败: ${xhr.status}`))
return
}
try {
const result = JSON.parse(xhr.responseText) as UploadResponse
if (result.code === 200 && result.data?.[0]?.copyUrl) {
resolve(result.data[0].copyUrl)
return
}
reject(new Error(result.msg || `图片上传失败: ${result.code}`))
} catch {
reject(new Error('解析上传响应失败'))
}
}
xhr.onerror = () => {
reject(new Error('网络错误,请检查网络连接'))
}
xhr.send(formData)
})
}
@@ -0,0 +1,593 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadProps, UploadRequestOptions } from 'element-plus'
import { ChatLineRound, DataAnalysis, Picture, Search } from '@element-plus/icons-vue'
import { closeTicket, fetchTickets, getTicketById, replyTicket } from '@/api/admin'
import type { AdminTicketDetail, AdminTicketListItem } from '@/types/api'
import { formatDateTime } from '@/utils/dashboard'
import {
buildTicketFilters,
getTicketLevelMeta,
getTicketStatusMeta,
renderTicketMarkdown,
} from '@/utils/tickets'
import { uploadImage } from '@/utils/upload'
import TrafficLogDialog from './TrafficLogDialog.vue'
const props = defineProps<{
visible: boolean
ticketId: number | null
statusPreset?: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
updated: []
}>()
const loadingSidebar = ref(false)
const loadingDetail = ref(false)
const replying = ref(false)
const sidebarTickets = ref<AdminTicketListItem[]>([])
const activeTicketId = ref<number | null>(null)
const detail = ref<AdminTicketDetail | null>(null)
const keyword = ref('')
const replyMessage = ref('')
const trafficVisible = ref(false)
const uploadingImage = ref(false)
type UploadError = Parameters<UploadRequestOptions['onError']>[0]
const statusMeta = computed(() => detail.value ? getTicketStatusMeta(detail.value) : null)
const levelMeta = computed(() => detail.value ? getTicketLevelMeta(detail.value.level) : null)
async function loadSidebarTickets() {
if (!props.visible) {
return
}
loadingSidebar.value = true
try {
const extra = buildTicketFilters(keyword.value, 'all')
const response = await fetchTickets({
current: 1,
pageSize: 20,
status: props.statusPreset,
email: extra.email,
filter: extra.filter,
})
sidebarTickets.value = response.data
if (!activeTicketId.value && response.data.length) {
activeTicketId.value = response.data[0].id
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '工单列表加载失败')
} finally {
loadingSidebar.value = false
}
}
async function loadDetail(id: number) {
loadingDetail.value = true
try {
const response = await getTicketById(id)
detail.value = response.data
activeTicketId.value = id
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '工单详情加载失败')
} finally {
loadingDetail.value = false
}
}
async function refreshWorkspace() {
await loadSidebarTickets()
if (activeTicketId.value) {
await loadDetail(activeTicketId.value)
}
}
async function handleReply() {
if (!detail.value || !replyMessage.value.trim()) {
return
}
replying.value = true
try {
await replyTicket(detail.value.id, replyMessage.value.trim())
replyMessage.value = ''
ElMessage.success('工单已回复')
await refreshWorkspace()
emit('updated')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '工单回复失败')
} finally {
replying.value = false
}
}
const beforeImageUpload: UploadProps['beforeUpload'] = (rawFile) => {
if (!rawFile.type.startsWith('image/')) {
ElMessage.error('仅支持上传图片文件')
return false
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('图片大小不能超过 10MB')
return false
}
return true
}
async function handleImageUploadRequest(options: UploadRequestOptions) {
uploadingImage.value = true
try {
const url = await uploadImage(options.file)
const markdown = `![image](${url})`
replyMessage.value = replyMessage.value
? `${replyMessage.value}\n${markdown}\n`
: `${markdown}\n`
options.onSuccess({ url })
ElMessage.success('图片上传成功')
} catch (error) {
const message = error instanceof Error ? error.message : '图片上传失败'
options.onError(Object.assign(new Error(message), {
status: 500,
method: 'POST',
url: '/upload/rest/upload',
}) as UploadError)
ElMessage.error(message)
} finally {
uploadingImage.value = false
}
}
async function handleCloseTicket() {
if (!detail.value || detail.value.status === 1) {
return
}
await ElMessageBox.confirm(`确认关闭工单 #${detail.value.id} 吗?`, '关闭工单', {
type: 'warning',
})
try {
await closeTicket(detail.value.id)
ElMessage.success('工单已关闭')
await refreshWorkspace()
emit('updated')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '关闭工单失败')
}
}
function closeDialog() {
emit('update:visible', false)
}
watch(
() => props.visible,
async (visible) => {
if (!visible) {
return
}
activeTicketId.value = props.ticketId
await refreshWorkspace()
},
{ immediate: true },
)
watch(
() => props.ticketId,
async (ticketId) => {
if (!props.visible || ticketId === null) {
return
}
activeTicketId.value = ticketId
await loadDetail(ticketId)
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="92vw"
top="3vh"
append-to-body
destroy-on-close
class="ticket-workspace-dialog"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<template #header>
<div class="workspace-header">
<div class="workspace-title">
<p>Ticket Workspace</p>
<h2>{{ detail?.subject || '工单详情' }}</h2>
</div>
<div class="workspace-header__actions">
<ElButton
v-if="detail?.user?.id"
text
class="ghost-action"
@click="trafficVisible = true"
>
<ElIcon><DataAnalysis /></ElIcon>
流量日志
</ElButton>
<ElButton
v-if="detail && detail.status !== 1"
text
class="ghost-action danger-action"
@click="handleCloseTicket"
>
关闭工单
</ElButton>
</div>
</div>
</template>
<div class="workspace-shell">
<aside class="workspace-sidebar">
<div class="sidebar-header">
<strong>工单列表</strong>
<ElInput
v-model="keyword"
placeholder="搜索工单标题或用户邮箱"
clearable
@keyup.enter="loadSidebarTickets"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
<div class="sidebar-list" v-loading="loadingSidebar">
<button
v-for="item in sidebarTickets"
:key="item.id"
type="button"
class="sidebar-ticket"
:class="{ active: item.id === activeTicketId }"
@click="loadDetail(item.id)"
>
<div class="sidebar-ticket__row">
<strong>{{ item.subject }}</strong>
<ElTag size="small" round :type="getTicketStatusMeta(item).type">
{{ getTicketStatusMeta(item).label }}
</ElTag>
</div>
<span>{{ item.user?.email || '未知用户' }}</span>
<div class="sidebar-ticket__meta">
<small>{{ formatDateTime(item.created_at) }}</small>
<ElTag size="small" effect="plain" round :type="getTicketLevelMeta(item.level).type">
{{ getTicketLevelMeta(item.level).label }}
</ElTag>
</div>
</button>
</div>
</aside>
<section class="workspace-main" v-loading="loadingDetail">
<template v-if="detail">
<header class="conversation-header">
<div>
<h3>{{ detail.subject }}</h3>
<div class="conversation-meta">
<span>{{ detail.user?.email || '未知用户' }}</span>
<span>创建于 {{ formatDateTime(detail.created_at) }}</span>
</div>
</div>
<div class="conversation-tags">
<ElTag v-if="levelMeta" round effect="plain" :type="levelMeta.type">
{{ levelMeta.label }}
</ElTag>
<ElTag v-if="statusMeta" round :type="statusMeta.type">
{{ statusMeta.label }}
</ElTag>
</div>
</header>
<div class="message-thread">
<article
v-for="message in detail.messages"
:key="message.id"
class="message-card"
:class="message.is_from_admin ? 'from-admin' : 'from-user'"
>
<div class="message-card__meta">
<span>{{ message.is_from_admin ? '管理员' : detail.user?.email || '用户' }}</span>
<small>{{ formatDateTime(message.created_at) }}</small>
</div>
<div class="message-card__body markdown-body" v-html="renderTicketMarkdown(message.message)" />
</article>
</div>
<footer class="reply-box">
<ElInput
v-model="replyMessage"
type="textarea"
:rows="3"
resize="none"
placeholder="输入回复内容..."
/>
<div class="reply-box__actions">
<ElUpload
:show-file-list="false"
accept="image/*"
:before-upload="beforeImageUpload"
:http-request="handleImageUploadRequest"
>
<ElButton text class="ghost-action" :loading="uploadingImage">
<ElIcon><Picture /></ElIcon>
上传图片
</ElButton>
</ElUpload>
<ElButton
type="primary"
:icon="ChatLineRound"
:loading="replying"
:disabled="detail.status === 1"
@click="handleReply"
>
发送
</ElButton>
</div>
</footer>
</template>
<div v-else class="workspace-empty">
请选择一个工单查看会话详情
</div>
</section>
</div>
<TrafficLogDialog
v-model:visible="trafficVisible"
:user-id="detail?.user?.id ?? null"
:user-email="detail?.user?.email"
/>
</ElDialog>
</template>
<style scoped>
.workspace-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.workspace-title p {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--xboard-text-muted);
}
.workspace-title h2 {
font-size: 34px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.workspace-header__actions {
display: flex;
align-items: center;
gap: 10px;
}
.ghost-action {
color: var(--xboard-link);
}
.danger-action {
color: var(--xboard-danger);
}
.workspace-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 70vh;
border: 1px solid var(--xboard-border);
border-radius: 24px;
overflow: hidden;
}
.workspace-sidebar {
display: grid;
grid-template-rows: auto 1fr;
border-right: 1px solid var(--xboard-border);
background: #fbfbfd;
}
.sidebar-header {
display: grid;
gap: 12px;
padding: 20px;
border-bottom: 1px solid var(--xboard-border);
}
.sidebar-list {
display: grid;
align-content: start;
gap: 8px;
padding: 16px;
overflow-y: auto;
}
.sidebar-ticket {
border: 1px solid transparent;
background: #ffffff;
border-radius: 18px;
padding: 16px;
text-align: left;
display: grid;
gap: 8px;
cursor: pointer;
transition: 0.2s ease;
}
.sidebar-ticket.active {
border-color: rgba(0, 113, 227, 0.24);
background: rgba(0, 113, 227, 0.08);
}
.sidebar-ticket__row,
.sidebar-ticket__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sidebar-ticket span,
.sidebar-ticket small {
color: var(--xboard-text-muted);
}
.workspace-main {
display: grid;
grid-template-rows: auto 1fr auto;
background: #ffffff;
min-height: 0;
}
.conversation-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid var(--xboard-border);
}
.conversation-header h3 {
font-size: 32px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.conversation-meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 8px;
color: var(--xboard-text-muted);
}
.conversation-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.message-thread {
display: grid;
align-content: start;
gap: 16px;
padding: 24px;
overflow-y: auto;
background: linear-gradient(180deg, #ffffff 0%, #fbfbfd 100%);
}
.message-card {
display: grid;
gap: 10px;
max-width: min(720px, 100%);
padding: 18px 20px;
border-radius: 20px;
box-shadow: var(--xboard-shadow);
}
.from-user {
background: #eef3fb;
}
.from-admin {
background: #1d1d1f;
color: #ffffff;
margin-left: auto;
}
.message-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.from-user .message-card__meta {
color: var(--xboard-text-muted);
}
.from-admin .message-card__meta {
color: rgba(255, 255, 255, 0.72);
}
.markdown-body :deep(p),
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3),
.markdown-body :deep(ul),
.markdown-body :deep(ol) {
margin: 0 0 10px;
}
.markdown-body :deep(img) {
max-width: min(420px, 100%);
border-radius: 14px;
}
.markdown-body :deep(a) {
color: inherit;
text-decoration: underline;
}
.reply-box {
display: grid;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid var(--xboard-border);
background: rgba(255, 255, 255, 0.92);
}
.reply-box__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
}
.workspace-empty {
display: grid;
place-items: center;
color: var(--xboard-text-muted);
}
@media (max-width: 1023px) {
.workspace-shell {
grid-template-columns: 1fr;
}
.workspace-sidebar {
border-right: 0;
border-bottom: 1px solid var(--xboard-border);
}
.workspace-header,
.conversation-header {
flex-direction: column;
align-items: stretch;
}
}
</style>
+352 -49
View File
@@ -1,77 +1,380 @@
<script setup lang="ts">
const features = [
'工单列表与状态筛选',
'工单会话详情与回复',
'关闭工单与处理记录',
]
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { DataAnalysis, Search, View } from '@element-plus/icons-vue'
import { closeTicket, fetchTickets } from '@/api/admin'
import type { AdminTicketListItem } from '@/types/api'
import { formatDateTime } from '@/utils/dashboard'
import {
buildTicketFilters,
getTicketLevelMeta,
getTicketStatusMeta,
} from '@/utils/tickets'
import TicketWorkspaceDialog from './TicketWorkspaceDialog.vue'
const loading = ref(false)
const tickets = ref<AdminTicketListItem[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const statusFilter = ref<'opening' | 'closed' | 'all'>('opening')
const levelFilter = ref('all')
const workspaceVisible = ref(false)
const activeTicketId = ref<number | null>(null)
const headerStats = computed(() => [
{
label: statusFilter.value === 'opening' ? '处理中' : statusFilter.value === 'closed' ? '已关闭' : '全部工单',
value: String(total.value),
},
{ label: '当前页', value: String(current.value) },
])
function statusValueToParam() {
if (statusFilter.value === 'opening') {
return 0
}
if (statusFilter.value === 'closed') {
return 1
}
return undefined
}
async function loadTickets() {
loading.value = true
try {
const extra = buildTicketFilters(keyword.value, levelFilter.value)
const response = await fetchTickets({
current: current.value,
pageSize: pageSize.value,
status: statusValueToParam(),
email: extra.email,
filter: extra.filter,
})
tickets.value = response.data
total.value = response.total
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '工单列表加载失败')
} finally {
loading.value = false
}
}
function openWorkspace(ticket: AdminTicketListItem) {
activeTicketId.value = ticket.id
workspaceVisible.value = true
}
async function handleCloseFromTable(ticket: AdminTicketListItem) {
if (ticket.status === 1) {
return
}
await ElMessageBox.confirm(`确认关闭工单 #${ticket.id} 吗?`, '关闭工单', {
type: 'warning',
})
try {
await closeTicket(ticket.id)
ElMessage.success('工单已关闭')
await loadTickets()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '关闭工单失败')
}
}
function handleSearch() {
current.value = 1
void loadTickets()
}
watch([current, pageSize], () => {
void loadTickets()
})
watch([statusFilter, levelFilter], () => {
current.value = 1
void loadTickets()
})
onMounted(() => {
void loadTickets()
})
</script>
<template>
<section class="tickets-placeholder">
<div class="placeholder-copy">
<p>Tickets</p>
<h1>工单管理将在下一步补齐</h1>
<span>本轮先把导航和路由结构铺平避免后续再改后台信息架构</span>
</div>
<div class="tickets-page">
<section class="tickets-hero">
<div class="tickets-copy">
<p class="tickets-kicker">Tickets</p>
<h1>工单管理</h1>
<span>在这里可以查看用户工单包括查看回复关闭与流量日志辅助排查</span>
</div>
<div class="placeholder-card">
<strong>预留能力</strong>
<ul>
<li v-for="item in features" :key="item">{{ item }}</li>
</ul>
</div>
</section>
<div class="hero-stats">
<article v-for="item in headerStats" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="ticket-board">
<header class="ticket-toolbar">
<div class="ticket-filters">
<ElSegmented
v-model="statusFilter"
:options="[
{ label: '处理中', value: 'opening' },
{ label: '已关闭', value: 'closed' },
{ label: '全部', value: 'all' },
]"
/>
<ElSelect v-model="levelFilter" class="toolbar-select" placeholder="优先级">
<ElOption label="全部优先级" value="all" />
<ElOption label="低优先" value="0" />
<ElOption label="中优先" value="1" />
<ElOption label="高优先" value="2" />
</ElSelect>
<ElInput
v-model="keyword"
clearable
placeholder="搜索工单标题或用户邮箱"
class="toolbar-search"
@keyup.enter="handleSearch"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
</header>
<ElTable :data="tickets" v-loading="loading" class="ticket-table" row-key="id">
<ElTableColumn label="工单号" width="92">
<template #default="{ row }">
<ElTag effect="plain" round>#{{ row.id }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="主题" min-width="280">
<template #default="{ row }">
<div class="subject-cell">
<strong>{{ row.subject }}</strong>
<span>{{ row.user?.email || '未知用户' }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="优先级" width="110">
<template #default="{ row }">
<ElTag round effect="plain" :type="getTicketLevelMeta(row.level).type">
{{ getTicketLevelMeta(row.level).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="{ row }">
<ElTag round :type="getTicketStatusMeta(row).type">
{{ getTicketStatusMeta(row).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="最后更新" width="150">
<template #default="{ row }">
{{ formatDateTime(row.updated_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" width="150">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="134" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text class="action-btn" @click="openWorkspace(row)">
<ElIcon><View /></ElIcon>
</ElButton>
<ElButton
text
class="action-btn danger-btn"
:disabled="row.status === 1"
@click="handleCloseFromTable(row)"
>
<ElIcon><DataAnalysis /></ElIcon>
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<footer class="ticket-footer">
<span>已选择 0 {{ total }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</section>
<TicketWorkspaceDialog
v-model:visible="workspaceVisible"
:ticket-id="activeTicketId"
:status-preset="statusValueToParam()"
@updated="loadTickets"
/>
</div>
</template>
<style scoped>
.tickets-placeholder {
.tickets-page {
display: grid;
gap: 20px;
padding: 32px;
gap: 24px;
}
.tickets-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.tickets-copy {
display: grid;
gap: 10px;
max-width: 680px;
}
.tickets-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.tickets-copy h1 {
font-size: clamp(36px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.tickets-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 260px;
}
.hero-stats article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.hero-stats span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.hero-stats strong {
color: #ffffff;
font-size: 22px;
}
.ticket-board {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.placeholder-copy {
display: grid;
gap: 8px;
.ticket-toolbar,
.ticket-filters,
.ticket-footer,
.action-group {
display: flex;
align-items: center;
gap: 12px;
}
.placeholder-copy p {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
.ticket-filters {
flex-wrap: wrap;
}
.toolbar-select {
width: 150px;
}
.toolbar-search {
width: min(320px, 100%);
}
.ticket-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.ticket-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.subject-cell {
display: grid;
gap: 6px;
}
.subject-cell strong {
color: var(--xboard-text-strong);
}
.subject-cell span,
.ticket-footer span {
color: var(--xboard-text-muted);
}
.placeholder-copy h1 {
font-size: clamp(30px, 5vw, 44px);
line-height: 1.1;
letter-spacing: -0.28px;
.action-btn {
font-size: 18px;
}
.placeholder-copy span {
color: var(--xboard-text-secondary);
max-width: 620px;
line-height: 1.47;
.danger-btn {
color: var(--xboard-danger);
}
.placeholder-card {
padding: 22px 24px;
border-radius: 22px;
background: #f5f5f7;
.ticket-footer {
justify-content: space-between;
}
.placeholder-card strong {
display: block;
margin-bottom: 12px;
}
@media (max-width: 1080px) {
.tickets-hero,
.ticket-footer {
flex-direction: column;
align-items: stretch;
}
.placeholder-card ul {
display: grid;
gap: 8px;
padding-left: 18px;
color: var(--xboard-text-secondary);
.hero-stats {
min-width: 0;
}
}
</style>
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchUserTrafficLogs } from '@/api/admin'
import type { AdminTrafficLogItem, TrafficAmount } from '@/types/api'
import { formatDateTime, formatTraffic } from '@/utils/dashboard'
import { formatServerRate, getTrafficTotal } from '@/utils/tickets'
const props = defineProps<{
visible: boolean
userId: number | null
userEmail?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const loading = ref(false)
const records = ref<AdminTrafficLogItem[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(20)
const timeRange = ref<string[] | []>([])
const summary = ref<TrafficAmount>({
upload: 0,
download: 0,
total: 0,
})
async function loadRecords() {
if (!props.visible || !props.userId) {
records.value = []
total.value = 0
summary.value = { upload: 0, download: 0, total: 0 }
return
}
const hasRange = Array.isArray(timeRange.value) && timeRange.value.length === 2
const startTime = hasRange ? Number(timeRange.value[0]) : undefined
const endTime = hasRange ? Number(timeRange.value[1]) : undefined
loading.value = true
try {
const response = await fetchUserTrafficLogs({
userId: props.userId,
pageSize: pageSize.value,
page: current.value,
minTotal: 500 * 1024,
startTime,
endTime,
})
records.value = response.data
total.value = response.total
summary.value = response.summary ?? { upload: 0, download: 0, total: 0 }
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '流量日志加载失败')
} finally {
loading.value = false
}
}
watch(
() => [props.visible, props.userId],
([visible, userId]) => {
if (!visible || !userId) {
return
}
current.value = 1
void loadRecords()
},
{ immediate: true },
)
watch([current, pageSize], () => {
void loadRecords()
})
function handleRangeChange() {
current.value = 1
void loadRecords()
}
</script>
<template>
<ElDialog
:model-value="props.visible"
width="760px"
class="traffic-log-dialog"
append-to-body
destroy-on-close
@close="emit('update:visible', false)"
@update:model-value="emit('update:visible', $event)"
>
<template #header>
<div class="dialog-header">
<div>
<p>Traffic Logs</p>
<h2>流量使用记录</h2>
</div>
<span>{{ props.userEmail || '未知用户' }}</span>
</div>
</template>
<div class="dialog-body">
<div class="dialog-toolbar">
<ElDatePicker
v-model="timeRange"
type="datetimerange"
value-format="X"
start-placeholder="开始时间按日志显示时间"
end-placeholder="结束时间按日志显示时间"
range-separator=""
clearable
@change="handleRangeChange"
/>
</div>
<div class="summary-grid">
<article>
<span>当前筛选上行</span>
<strong>{{ formatTraffic(summary.upload) }}</strong>
</article>
<article>
<span>当前筛选下行</span>
<strong>{{ formatTraffic(summary.download) }}</strong>
</article>
<article>
<span>当前搜索流量综合</span>
<strong>{{ formatTraffic(summary.total) }}</strong>
</article>
</div>
<ElTable :data="records" v-loading="loading" class="traffic-table" row-key="id">
<ElTableColumn label="时间" min-width="132">
<template #default="{ row }">
{{ formatDateTime(row.display_at || row.record_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="上传流量" min-width="110">
<template #default="{ row }">
{{ formatTraffic(row.u) }}
</template>
</ElTableColumn>
<ElTableColumn label="下行流量" min-width="110">
<template #default="{ row }">
{{ formatTraffic(row.d) }}
</template>
</ElTableColumn>
<ElTableColumn label="倍率" width="86">
<template #default="{ row }">
{{ formatServerRate(row.server_rate) }}
</template>
</ElTableColumn>
<ElTableColumn label="节点" min-width="160">
<template #default="{ row }">
{{ row.node_name || row.server_name || 'Unknown' }}
</template>
</ElTableColumn>
<ElTableColumn label="总计" min-width="110">
<template #default="{ row }">
{{ formatTraffic(getTrafficTotal(row)) }}
</template>
</ElTableColumn>
</ElTable>
<footer class="dialog-footer">
<span> {{ total }} 条记录</span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</div>
</ElDialog>
</template>
<style scoped>
.dialog-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.dialog-header p {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--xboard-text-muted);
}
.dialog-header h2 {
font-size: 30px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.dialog-header span {
color: var(--xboard-text-secondary);
}
.dialog-body {
display: grid;
gap: 16px;
}
.dialog-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-grid article {
display: grid;
gap: 6px;
padding: 16px 18px;
border-radius: 16px;
background: #f5f5f7;
}
.summary-grid span {
color: var(--xboard-text-muted);
font-size: 12px;
}
.summary-grid strong {
color: var(--xboard-text-strong);
font-size: 22px;
}
.traffic-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dialog-footer span {
color: var(--xboard-text-muted);
}
@media (max-width: 767px) {
.dialog-header,
.dialog-footer,
.dialog-toolbar {
flex-direction: column;
align-items: stretch;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>
+21 -3
View File
@@ -1,11 +1,18 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
const backendTarget = 'https://jc-kzmb.ikuncdn.com'
const uploadTarget = 'https://pic.535888.xyz'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const uploadAuthToken = env.DEV_UPLOAD_AUTH_TOKEN || ''
return {
base: '/assets/admin/',
resolve: {
alias: {
@@ -28,13 +35,24 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'https://jc-kzmb.ikuncdn.com',
target: backendTarget,
changeOrigin: true,
},
'/upload': {
target: uploadTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/upload/, ''),
headers: uploadAuthToken
? {
Authorization: uploadAuthToken,
}
: undefined,
},
},
},
build: {
outDir: '../public/assets/admin',
emptyOutDir: true,
},
}
})