From 60b5c99e748ed1771da31840fa9db8191df0f53e Mon Sep 17 00:00:00 2001 From: yinjianm Date: Tue, 21 Apr 2026 23:13:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ✨ feat(admin): add ticket workspace and traffic logs --- admin-frontend/package-lock.json | 92 +++ admin-frontend/package.json | 2 + admin-frontend/src/api/admin.ts | 44 ++ admin-frontend/src/types/api.d.ts | 63 ++ admin-frontend/src/types/components.d.ts | 3 + admin-frontend/src/utils/tickets.ts | 101 +++ admin-frontend/src/utils/upload.ts | 43 ++ .../views/tickets/TicketWorkspaceDialog.vue | 593 ++++++++++++++++++ .../src/views/tickets/TicketsView.vue | 401 ++++++++++-- .../src/views/tickets/TrafficLogDialog.vue | 271 ++++++++ admin-frontend/vite.config.ts | 24 +- .../Controllers/V2/Admin/StatController.php | 32 +- 12 files changed, 1614 insertions(+), 55 deletions(-) create mode 100644 admin-frontend/src/utils/tickets.ts create mode 100644 admin-frontend/src/utils/upload.ts create mode 100644 admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue create mode 100644 admin-frontend/src/views/tickets/TrafficLogDialog.vue diff --git a/admin-frontend/package-lock.json b/admin-frontend/package-lock.json index 5884093..22caeda 100644 --- a/admin-frontend/package-lock.json +++ b/admin-frontend/package-lock.json @@ -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", diff --git a/admin-frontend/package.json b/admin-frontend/package.json index f06cc43..debbc0f 100644 --- a/admin-frontend/package.json +++ b/admin-frontend/package.json @@ -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", diff --git a/admin-frontend/src/api/admin.ts b/admin-frontend/src/api/admin.ts index 1202c41..67d87e8 100644 --- a/admin-frontend/src/api/admin.ts +++ b/admin-frontend/src/api/admin.ts @@ -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> { export function deleteUser(id: number): Promise> { return unwrapPost('/user/destroy', { id }) } + +export function fetchTickets(params: AdminTicketFetchParams): Promise> { + return adminClient + .get>('/ticket/fetch', { params }) + .then((res) => res.data) +} + +export function getTicketById(id: number): Promise> { + return unwrap('/ticket/fetch', { id }) +} + +export function replyTicket(id: number, message: string): Promise> { + return unwrapPost('/ticket/reply', { id, message }) +} + +export function closeTicket(id: number): Promise> { + return unwrapPost('/ticket/close', { id }) +} + +export function fetchUserTrafficLogs(params: { + userId: number + pageSize?: number + page?: number + minTotal?: number + startTime?: number + endTime?: number +}): Promise { + return adminClient + .get('/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) +} diff --git a/admin-frontend/src/types/api.d.ts b/admin-frontend/src/types/api.d.ts index a93bfcd..2338c1a 100644 --- a/admin-frontend/src/types/api.d.ts +++ b/admin-frontend/src/types/api.d.ts @@ -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 { + summary: TrafficAmount +} + declare global { interface Window { settings?: { diff --git a/admin-frontend/src/types/components.d.ts b/admin-frontend/src/types/components.d.ts index e0dc6a8..bd1ba04 100644 --- a/admin-frontend/src/types/components.d.ts +++ b/admin-frontend/src/types/components.d.ts @@ -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'] } diff --git a/admin-frontend/src/utils/tickets.ts b/admin-frontend/src/utils/tickets.ts new file mode 100644 index 0000000..ab8bfb2 --- /dev/null +++ b/admin-frontend/src/utils/tickets.ts @@ -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): 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 { + 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): 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` +} diff --git a/admin-frontend/src/utils/upload.ts b/admin-frontend/src/utils/upload.ts new file mode 100644 index 0000000..8318f74 --- /dev/null +++ b/admin-frontend/src/utils/upload.ts @@ -0,0 +1,43 @@ +interface UploadResponse { + code: number + msg: string + data?: Array<{ + copyUrl: string + }> +} + +export async function uploadImage(file: File): Promise { + 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) + }) +} diff --git a/admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue b/admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue new file mode 100644 index 0000000..a05ff32 --- /dev/null +++ b/admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue @@ -0,0 +1,593 @@ + + + + + diff --git a/admin-frontend/src/views/tickets/TicketsView.vue b/admin-frontend/src/views/tickets/TicketsView.vue index 96615f8..23ca742 100644 --- a/admin-frontend/src/views/tickets/TicketsView.vue +++ b/admin-frontend/src/views/tickets/TicketsView.vue @@ -1,77 +1,380 @@ diff --git a/admin-frontend/src/views/tickets/TrafficLogDialog.vue b/admin-frontend/src/views/tickets/TrafficLogDialog.vue new file mode 100644 index 0000000..78994c3 --- /dev/null +++ b/admin-frontend/src/views/tickets/TrafficLogDialog.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/admin-frontend/vite.config.ts b/admin-frontend/vite.config.ts index 9c2757c..98e1ca6 100644 --- a/admin-frontend/vite.config.ts +++ b/admin-frontend/vite.config.ts @@ -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, }, + } }) diff --git a/app/Http/Controllers/V2/Admin/StatController.php b/app/Http/Controllers/V2/Admin/StatController.php index 3558b04..317583f 100644 --- a/app/Http/Controllers/V2/Admin/StatController.php +++ b/app/Http/Controllers/V2/Admin/StatController.php @@ -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), + ], ]; }