新增: AI工单处理工作台 v1.0
- Go Gin 后端 (19个源文件): 认证、工单CRUD、GLM AI分析、状态流转、备注、操作日志 - Arco Design Vue 前端: 登录、工单列表/详情/创建、AI分析触发与确认 - MySQL 5表: ticket_user/ticket_info/ticket_ai_analysis/ticket_operation_log/ticket_note - 部署: tk.1216.top HTTPS, Nginx反代
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
"axios": "^1.16.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
24
frontend/src/App.vue
Normal file
24
frontend/src/App.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<a-config-provider>
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
49
frontend/src/api/interceptor.ts
Normal file
49
frontend/src/api/interceptor.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios from 'axios'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
export interface HttpResponse<T = unknown> {
|
||||
success: boolean
|
||||
retcode: number
|
||||
retinfo: string
|
||||
result: T
|
||||
}
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = token
|
||||
config.headers.jsessionid = token
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response): any => {
|
||||
const res: HttpResponse = response.data
|
||||
const { success, retcode, retinfo, result } = res
|
||||
|
||||
if (success) {
|
||||
return result
|
||||
}
|
||||
|
||||
if (retcode === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(new Error('未登录'))
|
||||
}
|
||||
|
||||
Message.error(retinfo || '请求失败')
|
||||
return Promise.reject(new Error(retinfo || '请求失败'))
|
||||
},
|
||||
(error) => {
|
||||
Message.error(error.message || '网络错误')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default axiosInstance
|
||||
55
frontend/src/api/ticket.ts
Normal file
55
frontend/src/api/ticket.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import axiosInstance from './interceptor'
|
||||
import type {
|
||||
Ticket,
|
||||
TicketFilter,
|
||||
TicketListResponse,
|
||||
CreateTicketRequest,
|
||||
UpdateTicketRequest,
|
||||
AIAnalysisResponse,
|
||||
TicketNote,
|
||||
CreateNoteRequest
|
||||
} from '@/types'
|
||||
|
||||
export function getTicketList(filter: TicketFilter) {
|
||||
return axiosInstance.get('/tickets', { params: filter }) as Promise<TicketListResponse>
|
||||
}
|
||||
|
||||
export function getTicketDetail(id: number) {
|
||||
return axiosInstance.get(`/tickets/${id}`) as Promise<Ticket>
|
||||
}
|
||||
|
||||
export function createTicket(data: CreateTicketRequest) {
|
||||
return axiosInstance.post('/tickets', data) as Promise<Ticket>
|
||||
}
|
||||
|
||||
export function updateTicket(id: number, data: UpdateTicketRequest) {
|
||||
return axiosInstance.put(`/tickets/${id}`, data) as Promise<Ticket>
|
||||
}
|
||||
|
||||
export function updateTicketStatus(id: number, status: number) {
|
||||
return axiosInstance.put(`/tickets/${id}/status`, { status }) as Promise<Ticket>
|
||||
}
|
||||
|
||||
export function analyzeTicket(ticketId: number) {
|
||||
return axiosInstance.post(`/tickets/${ticketId}/analyze`) as Promise<AIAnalysisResponse>
|
||||
}
|
||||
|
||||
export function getAnalysis(ticketId: number) {
|
||||
return axiosInstance.get(`/tickets/${ticketId}/analysis`) as Promise<AIAnalysisResponse>
|
||||
}
|
||||
|
||||
export function confirmAnalysis(ticketId: number, data: Partial<AIAnalysisResponse>) {
|
||||
return axiosInstance.put(`/tickets/${ticketId}/analysis`, data) as Promise<AIAnalysisResponse>
|
||||
}
|
||||
|
||||
export function getTicketNotes(ticketId: number) {
|
||||
return axiosInstance.get(`/tickets/${ticketId}/notes`) as Promise<TicketNote[]>
|
||||
}
|
||||
|
||||
export function createNote(ticketId: number, data: CreateNoteRequest) {
|
||||
return axiosInstance.post(`/tickets/${ticketId}/notes`, data) as Promise<TicketNote>
|
||||
}
|
||||
|
||||
export function getTicketLogs(ticketId: number) {
|
||||
return axiosInstance.get(`/tickets/${ticketId}/logs`) as Promise<any[]>
|
||||
}
|
||||
14
frontend/src/api/user.ts
Normal file
14
frontend/src/api/user.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import axiosInstance from './interceptor'
|
||||
import type { LoginRequest, LoginResponse } from '@/types'
|
||||
|
||||
export function login(data: LoginRequest) {
|
||||
return axiosInstance.post('/auth/login', data) as Promise<LoginResponse>
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return axiosInstance.post('/auth/logout') as Promise<void>
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return axiosInstance.get('/auth/me') as Promise<LoginResponse['user']>
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
95
frontend/src/components/HelloWorld.vue
Normal file
95
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
92
frontend/src/constants/index.ts
Normal file
92
frontend/src/constants/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export const categoryOptions: Array<{ value: string; label: string }> = [
|
||||
{ value: 'refund', label: '退款申请' },
|
||||
{ value: 'login', label: '登录异常' },
|
||||
{ value: 'invoice', label: '发票问题' },
|
||||
{ value: 'logistics', label: '物流投诉' },
|
||||
{ value: 'account', label: '账户问题' },
|
||||
{ value: 'inquiry', label: '产品咨询' },
|
||||
{ value: 'other', label: '其他' }
|
||||
]
|
||||
|
||||
export const categoryMap: Record<string, string> = {
|
||||
refund: '退款申请',
|
||||
login: '登录异常',
|
||||
invoice: '发票问题',
|
||||
logistics: '物流投诉',
|
||||
account: '账户问题',
|
||||
inquiry: '产品咨询',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
export const priorityOptions: Array<{ value: number; label: string }> = [
|
||||
{ value: 0, label: 'P0 紧急' },
|
||||
{ value: 1, label: 'P1 高' },
|
||||
{ value: 2, label: 'P2 中' },
|
||||
{ value: 3, label: 'P3 低' }
|
||||
]
|
||||
|
||||
export const priorityMap: Record<number, string> = {
|
||||
0: 'P0 紧急',
|
||||
1: 'P1 高',
|
||||
2: 'P2 中',
|
||||
3: 'P3 低'
|
||||
}
|
||||
|
||||
export const priorityColor: Record<number, string> = {
|
||||
0: 'red',
|
||||
1: 'orangered',
|
||||
2: 'blue',
|
||||
3: 'gray'
|
||||
}
|
||||
|
||||
export const statusOptions: Array<{ value: number; label: string }> = [
|
||||
{ value: 0, label: '待处理' },
|
||||
{ value: 1, label: '分析中' },
|
||||
{ value: 2, label: '已确认' },
|
||||
{ value: 3, label: '处理中' },
|
||||
{ value: 4, label: '已关闭' }
|
||||
]
|
||||
|
||||
export const statusMap: Record<number, string> = {
|
||||
0: '待处理',
|
||||
1: '分析中',
|
||||
2: '已确认',
|
||||
3: '处理中',
|
||||
4: '已关闭'
|
||||
}
|
||||
|
||||
export const statusColor: Record<number, string> = {
|
||||
0: 'gray',
|
||||
1: 'blue',
|
||||
2: 'green',
|
||||
3: 'orange',
|
||||
4: 'gray'
|
||||
}
|
||||
|
||||
export const roleOptions: Array<{ value: string; label: string }> = [
|
||||
{ value: 'refund_team', label: '退款组' },
|
||||
{ value: 'tech_support', label: '技术支持' },
|
||||
{ value: 'finance_team', label: '财务组' },
|
||||
{ value: 'logistics_team', label: '物流组' },
|
||||
{ value: 'customer_service', label: '客服组' }
|
||||
]
|
||||
|
||||
export const roleMap: Record<string, string> = {
|
||||
refund_team: '退款组',
|
||||
tech_support: '技术支持',
|
||||
finance_team: '财务组',
|
||||
logistics_team: '物流组',
|
||||
customer_service: '客服组'
|
||||
}
|
||||
|
||||
export const sourceOptions: Array<{ value: string; label: string }> = [
|
||||
{ value: 'web', label: '网页' },
|
||||
{ value: 'phone', label: '电话' },
|
||||
{ value: 'email', label: '邮件' }
|
||||
]
|
||||
|
||||
export const sourceMap: Record<string, string> = {
|
||||
web: '网页',
|
||||
phone: '电话',
|
||||
email: '邮件'
|
||||
}
|
||||
16
frontend/src/main.ts
Normal file
16
frontend/src/main.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import ArcoVue from '@arco-design/web-vue'
|
||||
import ArcoVueIcon from '@arco-design/web-vue/es/icon'
|
||||
import '@arco-design/web-vue/dist/arco.css'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(ArcoVue)
|
||||
app.use(ArcoVueIcon)
|
||||
app.use(router)
|
||||
app.use(createPinia())
|
||||
|
||||
app.mount('#app')
|
||||
50
frontend/src/router/index.ts
Normal file
50
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/layout/index.vue'),
|
||||
redirect: '/tickets',
|
||||
children: [
|
||||
{
|
||||
path: 'tickets',
|
||||
name: 'TicketList',
|
||||
component: () => import('@/views/ticket/list.vue')
|
||||
},
|
||||
{
|
||||
path: 'tickets/create',
|
||||
name: 'TicketCreate',
|
||||
component: () => import('@/views/ticket/create.vue')
|
||||
},
|
||||
{
|
||||
path: 'tickets/:id',
|
||||
name: 'TicketDetail',
|
||||
component: () => import('@/views/ticket/detail.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.path !== '/login' && !token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && token) {
|
||||
next('/tickets')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
41
frontend/src/store/user.ts
Normal file
41
frontend/src/store/user.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { login as loginApi, logout as logoutApi } from '@/api/user'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string>(localStorage.getItem('token') || '')
|
||||
const username = ref<string>(localStorage.getItem('username') || '')
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
function setUsername(newUsername: string) {
|
||||
username.value = newUsername
|
||||
localStorage.setItem('username', newUsername)
|
||||
}
|
||||
|
||||
async function login(account: string, password: string) {
|
||||
const res = await loginApi({ account, password })
|
||||
setToken(res.token)
|
||||
setUsername(account)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await logoutApi()
|
||||
token.value = ''
|
||||
username.value = ''
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
username,
|
||||
setToken,
|
||||
setUsername,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
})
|
||||
296
frontend/src/style.css
Normal file
296
frontend/src/style.css
Normal file
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
90
frontend/src/types/index.ts
Normal file
90
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export interface Ticket {
|
||||
ticketid: number
|
||||
ticketno: string
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
priority: number
|
||||
status: number
|
||||
contactname: string
|
||||
contactphone: string
|
||||
source: string
|
||||
submitterid: number
|
||||
handlerid: number | null
|
||||
createtime: string
|
||||
updatetime: string
|
||||
}
|
||||
|
||||
export interface TicketFilter {
|
||||
status?: number
|
||||
category?: string
|
||||
priority?: number
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TicketListResponse {
|
||||
rows: Ticket[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: {
|
||||
userid: number
|
||||
username: string
|
||||
account: string
|
||||
role: number
|
||||
team: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateTicketRequest {
|
||||
title: string
|
||||
content: string
|
||||
contactname: string
|
||||
contactphone: string
|
||||
source: string
|
||||
submitterid?: number
|
||||
}
|
||||
|
||||
export interface UpdateTicketRequest {
|
||||
title?: string
|
||||
content?: string
|
||||
contactname?: string
|
||||
contactphone?: string
|
||||
source?: string
|
||||
category?: string
|
||||
priority?: number
|
||||
status?: number
|
||||
handlerid?: number | null
|
||||
}
|
||||
|
||||
export interface AIAnalysisResponse {
|
||||
analysisid: number
|
||||
ticketid: number
|
||||
category: string
|
||||
priority: number
|
||||
summary: string
|
||||
suggestrole: string
|
||||
confirmed: number
|
||||
createtime: string
|
||||
}
|
||||
|
||||
export interface TicketNote {
|
||||
noteid: number
|
||||
ticketid: number
|
||||
authorid: number
|
||||
content: string
|
||||
createtime: string
|
||||
}
|
||||
|
||||
export interface CreateNoteRequest {
|
||||
content: string
|
||||
}
|
||||
107
frontend/src/views/layout/index.vue
Normal file
107
frontend/src/views/layout/index.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<a-layout class="layout">
|
||||
<a-layout-sider class="sider" :width="200">
|
||||
<div class="logo">
|
||||
<h3>工单工作台</h3>
|
||||
</div>
|
||||
<a-menu
|
||||
:selected-keys="[currentRoute]"
|
||||
@menu-item-click="handleMenuClick"
|
||||
>
|
||||
<a-menu-item key="/tickets">
|
||||
<template #icon><icon-list /></template>
|
||||
工单列表
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/tickets/create">
|
||||
<template #icon><icon-plus /></template>
|
||||
创建工单
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-header class="header">
|
||||
<div class="header-right">
|
||||
<a-space>
|
||||
<span>{{ userStore.username }}</span>
|
||||
<a-button type="text" @click="handleLogout">
|
||||
<template #icon><icon-export /></template>
|
||||
退出
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import {
|
||||
IconList,
|
||||
IconPlus,
|
||||
IconExport
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const currentRoute = computed(() => route.path)
|
||||
|
||||
function handleMenuClick(key: string) {
|
||||
router.push(key)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sider {
|
||||
background: #001529;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo h3 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/views/login/index.vue
Normal file
84
frontend/src/views/login/index.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-header">
|
||||
<h1>AI 工单处理工作台</h1>
|
||||
</div>
|
||||
<a-form :model="form" @submit="handleLogin" layout="vertical">
|
||||
<a-form-item field="account" label="账号">
|
||||
<a-input v-model="form.account" placeholder="请输入账号" />
|
||||
</a-form-item>
|
||||
<a-form-item field="password" label="密码">
|
||||
<a-input-password v-model="form.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" long :loading="loading">
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const form = ref({
|
||||
account: 'admin',
|
||||
password: 'admin123'
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!form.value.account || !form.value.password) {
|
||||
Message.error('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(form.value.account, form.value.password)
|
||||
Message.success('登录成功')
|
||||
router.push('/tickets')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
100
frontend/src/views/ticket/create.vue
Normal file
100
frontend/src/views/ticket/create.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="ticket-create">
|
||||
<a-card title="创建工单" class="form-card">
|
||||
<a-form :model="form" @submit="handleSubmit" layout="vertical">
|
||||
<a-form-item
|
||||
field="title"
|
||||
label="标题"
|
||||
:rules="[{ required: true, message: '请输入工单标题' }]"
|
||||
>
|
||||
<a-input v-model="form.title" placeholder="请输入工单标题" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
field="content"
|
||||
label="内容"
|
||||
:rules="[{ required: true, message: '请输入工单内容' }]"
|
||||
>
|
||||
<a-textarea
|
||||
v-model="form.content"
|
||||
placeholder="请详细描述工单内容"
|
||||
:auto-size="{ minRows: 6, maxRows: 12 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item field="contactname" label="联系人姓名">
|
||||
<a-input v-model="form.contactname" placeholder="请输入联系人姓名" />
|
||||
</a-form-item>
|
||||
<a-form-item field="contactphone" label="联系电话">
|
||||
<a-input v-model="form.contactphone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
<a-form-item field="source" label="来源">
|
||||
<a-select v-model="form.source" placeholder="请选择工单来源">
|
||||
<a-option value="web">网页</a-option>
|
||||
<a-option value="phone">电话</a-option>
|
||||
<a-option value="email">邮件</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">
|
||||
提交工单
|
||||
</a-button>
|
||||
<a-button @click="handleCancel">
|
||||
取消
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { createTicket } from '@/api/ticket'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
contactname: '',
|
||||
contactphone: '',
|
||||
source: 'web',
|
||||
submitterid: 1
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.title || !form.content) {
|
||||
Message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const ticket = await createTicket(form) as any
|
||||
Message.success('工单创建成功')
|
||||
router.push(`/tickets/${ticket.ticketid}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-create {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
312
frontend/src/views/ticket/detail.vue
Normal file
312
frontend/src/views/ticket/detail.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="ticket-detail">
|
||||
<a-spin :loading="loading" style="width: 100%">
|
||||
<div v-if="ticket" class="detail-content">
|
||||
<a-card title="工单信息" class="info-card">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="工单编号">{{ ticket.ticketno }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor[ticket.status]">
|
||||
{{ statusMap[ticket.status] }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="标题" :span="2">{{ ticket.title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="内容" :span="2">
|
||||
<div class="content-text">{{ ticket.content }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="联系人">{{ ticket.contactname || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="联系电话">{{ ticket.contactphone || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="来源">
|
||||
{{ sourceMap[ticket.source] || ticket.source }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ ticket.createtime }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<div class="middle-section">
|
||||
<a-card title="AI 分析结果" class="analysis-card">
|
||||
<div v-if="!analysis" class="no-analysis">
|
||||
<a-button type="primary" @click="handleAnalyze" :loading="analyzing">
|
||||
触发 AI 分析
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-else class="analysis-form">
|
||||
<a-form :model="analysisForm" layout="vertical">
|
||||
<a-form-item label="分类">
|
||||
<a-select v-model="analysisForm.category" placeholder="选择分类">
|
||||
<a-option v-for="(label, key) in categoryMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="优先级">
|
||||
<a-select v-model="analysisForm.priority" placeholder="选择优先级">
|
||||
<a-option v-for="(label, key) in priorityMap" :key="key" :value="Number(key)">
|
||||
{{ label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="摘要">
|
||||
<a-textarea
|
||||
v-model="analysisForm.summary"
|
||||
placeholder="AI 生成的摘要"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="建议处理角色">
|
||||
<a-select v-model="analysisForm.suggestrole" placeholder="选择角色">
|
||||
<a-option v-for="(label, key) in roleMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleConfirmAnalysis" :loading="confirming">
|
||||
确认
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="操作" class="action-card">
|
||||
<a-space direction="vertical" fill>
|
||||
<div>
|
||||
<div class="action-label">状态流转</div>
|
||||
<a-button-group>
|
||||
<a-button
|
||||
v-for="(label, key) in statusMap"
|
||||
:key="key"
|
||||
:type="ticket.status === Number(key) ? 'primary' : 'outline'"
|
||||
:disabled="ticket.status === Number(key)"
|
||||
@click="handleUpdateStatus(Number(key))"
|
||||
>
|
||||
{{ label }}
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-card title="备注" class="log-card">
|
||||
<div class="note-section">
|
||||
<a-textarea
|
||||
v-model="newNote"
|
||||
placeholder="添加备注..."
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
/>
|
||||
<a-button type="primary" @click="handleAddNote" :loading="addingNote" style="margin-top: 8px">
|
||||
添加备注
|
||||
</a-button>
|
||||
</div>
|
||||
<a-timeline class="timeline">
|
||||
<a-timeline-item v-for="note in notes" :key="note.noteid">
|
||||
<div class="note-content">{{ note.content }}</div>
|
||||
<div class="note-time">{{ note.createtime }}</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
getTicketDetail,
|
||||
updateTicketStatus,
|
||||
analyzeTicket,
|
||||
getAnalysis,
|
||||
confirmAnalysis,
|
||||
getTicketNotes,
|
||||
createNote
|
||||
} from '@/api/ticket'
|
||||
import type { Ticket, TicketNote, AIAnalysisResponse } from '@/types'
|
||||
import {
|
||||
categoryMap,
|
||||
priorityMap,
|
||||
statusMap,
|
||||
statusColor,
|
||||
roleMap,
|
||||
sourceMap
|
||||
} from '@/constants'
|
||||
|
||||
const route = useRoute()
|
||||
const ticketId = Number(route.params.id)
|
||||
|
||||
const ticket = ref<Ticket | null>(null)
|
||||
const analysis = ref<AIAnalysisResponse | null>(null)
|
||||
const notes = ref<TicketNote[]>([])
|
||||
const loading = ref(false)
|
||||
const analyzing = ref(false)
|
||||
const confirming = ref(false)
|
||||
const addingNote = ref(false)
|
||||
const newNote = ref('')
|
||||
|
||||
const analysisForm = reactive({
|
||||
category: '',
|
||||
priority: 0,
|
||||
summary: '',
|
||||
suggestrole: ''
|
||||
})
|
||||
|
||||
async function fetchDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [ticketData, notesData] = await Promise.all([
|
||||
getTicketDetail(ticketId),
|
||||
getTicketNotes(ticketId)
|
||||
]) as any
|
||||
ticket.value = ticketData
|
||||
notes.value = notesData || []
|
||||
|
||||
// Try to fetch AI analysis
|
||||
try {
|
||||
const analysisData = await getAnalysis(ticketId) as any
|
||||
if (analysisData && analysisData.category) {
|
||||
analysis.value = analysisData
|
||||
analysisForm.category = analysisData.category
|
||||
analysisForm.priority = analysisData.priority
|
||||
analysisForm.summary = analysisData.summary
|
||||
analysisForm.suggestrole = analysisData.suggestrole
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
analyzing.value = true
|
||||
try {
|
||||
const res = await analyzeTicket(ticketId) as any
|
||||
analysisForm.category = res.category
|
||||
analysisForm.priority = res.priority
|
||||
analysisForm.summary = res.summary
|
||||
analysisForm.suggestrole = res.suggestrole
|
||||
analysis.value = res
|
||||
Message.success('AI 分析完成')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmAnalysis() {
|
||||
confirming.value = true
|
||||
try {
|
||||
await confirmAnalysis(ticketId, {
|
||||
category: analysisForm.category,
|
||||
priority: analysisForm.priority,
|
||||
summary: analysisForm.summary,
|
||||
suggestrole: analysisForm.suggestrole
|
||||
})
|
||||
Message.success('确认成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateStatus(status: number) {
|
||||
try {
|
||||
await updateTicketStatus(ticketId, status)
|
||||
Message.success('状态更新成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddNote() {
|
||||
if (!newNote.value.trim()) {
|
||||
Message.warning('请输入备注内容')
|
||||
return
|
||||
}
|
||||
addingNote.value = true
|
||||
try {
|
||||
await createNote(ticketId, { content: newNote.value })
|
||||
Message.success('备注添加成功')
|
||||
newNote.value = ''
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
addingNote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.middle-section {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.no-analysis {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.analysis-form {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.note-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.note-content {
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.note-time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
220
frontend/src/views/ticket/list.vue
Normal file
220
frontend/src/views/ticket/list.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="ticket-list">
|
||||
<a-card class="filter-card">
|
||||
<a-form :model="filter" layout="inline">
|
||||
<a-form-item label="状态">
|
||||
<a-select
|
||||
v-model="filter.status"
|
||||
placeholder="全部"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="分类">
|
||||
<a-select
|
||||
v-model="filter.category"
|
||||
placeholder="全部"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-option
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="优先级">
|
||||
<a-select
|
||||
v-model="filter.priority"
|
||||
placeholder="全部"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-option
|
||||
v-for="item in priorityOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="关键词">
|
||||
<a-input
|
||||
v-model="filter.keyword"
|
||||
placeholder="搜索标题或内容"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">查询</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card class="table-card">
|
||||
<a-table
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="工单编号" data-index="ticketno" :width="140" />
|
||||
<a-table-column title="标题" data-index="title" :width="200" :ellipsis="true" :tooltip="true" />
|
||||
<a-table-column title="分类" data-index="category" :width="100">
|
||||
<template #cell="{ record }">
|
||||
{{ categoryMap[record.category] || record.category }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="优先级" data-index="priority" :width="100">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="priorityColor[record.priority]">
|
||||
{{ priorityMap[record.priority] }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" data-index="status" :width="100">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="statusColor[record.status]">
|
||||
{{ statusMap[record.status] }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="联系人" data-index="contactname" :width="120" />
|
||||
<a-table-column title="提交时间" data-index="createtime" :width="180" />
|
||||
<a-table-column title="操作" :width="100" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-button type="text" @click="handleView(record.ticketid)">
|
||||
查看
|
||||
</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTicketList } from '@/api/ticket'
|
||||
import type { Ticket, TicketFilter } from '@/types'
|
||||
import {
|
||||
categoryOptions,
|
||||
categoryMap,
|
||||
priorityOptions,
|
||||
priorityMap,
|
||||
priorityColor,
|
||||
statusOptions,
|
||||
statusMap,
|
||||
statusColor
|
||||
} from '@/constants'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const filter = reactive<TicketFilter>({
|
||||
status: undefined,
|
||||
category: undefined,
|
||||
priority: undefined,
|
||||
keyword: '',
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const tableData = ref<Ticket[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTicketList(filter)
|
||||
tableData.value = res.rows
|
||||
total.value = res.total
|
||||
pagination.total = res.total
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.status = undefined
|
||||
filter.category = undefined
|
||||
filter.priority = undefined
|
||||
filter.keyword = ''
|
||||
filter.page = 1
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
filter.page = page
|
||||
pagination.current = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
filter.pageSize = pageSize
|
||||
pagination.pageSize = pageSize
|
||||
filter.page = 1
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleView(id: number) {
|
||||
router.push(`/tickets/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
19
frontend/tsconfig.app.json
Normal file
19
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8090',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user