✨ feat(admin): 补齐工单工作台与流量日志
---
✨ feat(admin): add ticket workspace and traffic logs
This commit is contained in:
Generated
+92
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+63
@@ -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
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 = ``
|
||||
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>
|
||||
@@ -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 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 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>
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -232,18 +232,39 @@ class StatController extends Controller
|
||||
public function getStatUser(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'user_id' => 'required|integer'
|
||||
'user_id' => 'required|integer',
|
||||
'min_total' => 'nullable|integer|min:0',
|
||||
'start_time' => 'nullable|integer|min:0',
|
||||
'end_time' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$pageSize = $request->input('pageSize', 10);
|
||||
$userId = (int) $request->input('user_id');
|
||||
$records = StatUser::query()
|
||||
$minTotal = max(0, (int) $request->input('min_total', 0));
|
||||
$startTime = $request->filled('start_time') ? (int) $request->input('start_time') : null;
|
||||
$endTime = $request->filled('end_time') ? (int) $request->input('end_time') : null;
|
||||
|
||||
$baseQuery = StatUser::query()
|
||||
->with(['server:id,name'])
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('record_at')
|
||||
->where('user_id', $userId)
|
||||
->paginate($pageSize);
|
||||
->when($startTime !== null, function ($query) use ($startTime) {
|
||||
$query->where('updated_at', '>=', $startTime);
|
||||
})
|
||||
->when($endTime !== null, function ($query) use ($endTime) {
|
||||
$query->where('updated_at', '<=', $endTime);
|
||||
})
|
||||
->when($minTotal > 0, function ($query) use ($minTotal) {
|
||||
$query->whereRaw('(u + d) >= ?', [$minTotal]);
|
||||
});
|
||||
|
||||
$summary = (clone $baseQuery)
|
||||
->selectRaw('COALESCE(SUM(u), 0) as upload, COALESCE(SUM(d), 0) as download, COALESCE(SUM(u + d), 0) as total')
|
||||
->first();
|
||||
|
||||
$records = $baseQuery->paginate($pageSize);
|
||||
|
||||
$deviceMap = $this->buildNodeDeviceMap($userId);
|
||||
$data = collect($records->items())
|
||||
@@ -266,6 +287,11 @@ class StatController extends Controller
|
||||
return [
|
||||
'data' => $data,
|
||||
'total' => $records->total(),
|
||||
'summary' => [
|
||||
'upload' => (int) ($summary->upload ?? 0),
|
||||
'download' => (int) ($summary->download ?? 0),
|
||||
'total' => (int) ($summary->total ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user