新增: 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:
2026-05-13 17:05:49 +08:00
commit 4793b1a533
51 changed files with 3650 additions and 0 deletions

13
frontend/index.html Normal file
View 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
View 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
View 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>

View 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

View 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
View 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']>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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

View 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>

View 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
View 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')

View 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

View 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
View 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);
}
}

View 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
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View 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
View 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
}
}
}
})