feat(工单): 完成工单系统的编写

This commit is contained in:
Boen_Shi 2026-06-18 23:16:49 +08:00
parent 00543543da
commit c6be29d756
8 changed files with 543 additions and 3 deletions

4
components.d.ts vendored
View File

@ -15,6 +15,7 @@ declare module 'vue' {
ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCascaderPanel: typeof import('element-plus/es')['ElCascaderPanel'] ElCascaderPanel: typeof import('element-plus/es')['ElCascaderPanel']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapse: typeof import('element-plus/es')['ElCollapse']
@ -46,6 +47,9 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -18,6 +18,7 @@ interface ApplyAccountPayload {
phone: string phone: string
password: string password: string
password_confirmation: string password_confirmation: string
application_note?: string
} }
interface MeData { interface MeData {

31
src/api/tickets.ts Normal file
View File

@ -0,0 +1,31 @@
import request from '@/axios'
export const ticketsApi = {
list(params: Record<string, unknown> = {}) {
return request.get('/tickets', { params })
},
detail(id: number) {
return request.get(`/tickets/${id}`)
},
create(data: Record<string, unknown>) {
return request.post('/tickets', data)
},
update(id: number, data: Record<string, unknown>) {
return request.put(`/tickets/${id}`, data)
},
reply(id: number, content: string) {
return request.post(`/tickets/${id}/messages`, { content })
},
categories(params: Record<string, unknown> = {}) {
return request.get('/ticket-categories', { params })
},
createCategory(data: Record<string, unknown>) {
return request.post('/ticket-categories', data)
},
updateCategory(id: number, data: Record<string, unknown>) {
return request.put(`/ticket-categories/${id}`, data)
},
removeCategory(id: number) {
return request.delete(`/ticket-categories/${id}`)
},
}

View File

@ -36,6 +36,7 @@
<el-menu-item v-if='hasPermission("platform.roles.view")' index='/roles'><el-icon><Checked /></el-icon>角色管理</el-menu-item> <el-menu-item v-if='hasPermission("platform.roles.view")' index='/roles'><el-icon><Checked /></el-icon>角色管理</el-menu-item>
<el-menu-item v-if='hasPermission("platform.permissions.view")' index='/permissions'><el-icon><Lock /></el-icon>权限管理</el-menu-item> <el-menu-item v-if='hasPermission("platform.permissions.view")' index='/permissions'><el-icon><Lock /></el-icon>权限管理</el-menu-item>
<el-menu-item v-if='hasPermission("platform.servers.view") || hasPermission("resource.servers.use")' index='/servers'><el-icon><Monitor /></el-icon>服务器资源</el-menu-item> <el-menu-item v-if='hasPermission("platform.servers.view") || hasPermission("resource.servers.use")' index='/servers'><el-icon><Monitor /></el-icon>服务器资源</el-menu-item>
<el-menu-item v-if='canAccessTickets' index='/tickets'><el-icon><Document /></el-icon>工单系统</el-menu-item>
<el-menu-item v-if='hasPermission("platform.accounts.view")' index='/accounts'><el-icon><Key /></el-icon>堡垒机账号</el-menu-item> <el-menu-item v-if='hasPermission("platform.accounts.view")' index='/accounts'><el-icon><Key /></el-icon>堡垒机账号</el-menu-item>
<el-menu-item v-if='hasPermission("platform.logs.view")' index='/logs'><el-icon><Document /></el-icon>访问日志</el-menu-item> <el-menu-item v-if='hasPermission("platform.logs.view")' index='/logs'><el-icon><Document /></el-icon>访问日志</el-menu-item>
<el-menu-item v-if='hasPermission("platform.oauth_clients.view")' index='/oauth-clients'><el-icon><Key /></el-icon>OAuth 客户端</el-menu-item> <el-menu-item v-if='hasPermission("platform.oauth_clients.view")' index='/oauth-clients'><el-icon><Key /></el-icon>OAuth 客户端</el-menu-item>
@ -62,6 +63,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { hasPermission } = authStore const { hasPermission } = authStore
const canAccessTickets = computed(() => hasPermission('tickets.use') || hasPermission('platform.tickets.view') || hasPermission('platform.tickets.manage'))
const siteTitleConfig = useConfigKey<string>(CONFIG_KEY_SITE_TITLE, DEFAULT_SITE_TITLE) const siteTitleConfig = useConfigKey<string>(CONFIG_KEY_SITE_TITLE, DEFAULT_SITE_TITLE)
const siteTitle = computed(() => (siteTitleConfig.value || DEFAULT_SITE_TITLE).trim() || DEFAULT_SITE_TITLE) const siteTitle = computed(() => (siteTitleConfig.value || DEFAULT_SITE_TITLE).trim() || DEFAULT_SITE_TITLE)

View File

@ -63,6 +63,9 @@
<el-form-item label='确认密码' prop='password_confirmation'> <el-form-item label='确认密码' prop='password_confirmation'>
<el-input v-model='applyForm.password_confirmation' type='password' show-password placeholder='请再次输入密码' /> <el-input v-model='applyForm.password_confirmation' type='password' show-password placeholder='请再次输入密码' />
</el-form-item> </el-form-item>
<el-form-item label='申请备注' prop='application_note'>
<el-input v-model='applyForm.application_note' type='textarea' :rows='4' maxlength='2000' show-word-limit placeholder='请输入申请原因、需要的权限或其他说明' />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click='applyDialogVisible = false'>取消</el-button> <el-button @click='applyDialogVisible = false'>取消</el-button>
@ -101,6 +104,7 @@ const applyForm = reactive({
phone: '', phone: '',
password: '', password: '',
password_confirmation: '', password_confirmation: '',
application_note: '',
}) })
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const phoneRegex = /^1[3-9]\d{9}$/ const phoneRegex = /^1[3-9]\d{9}$/
@ -153,6 +157,7 @@ const applyRules: FormRules = {
], ],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
password_confirmation: [{ required: true, message: '请再次输入密码', trigger: 'blur' }], password_confirmation: [{ required: true, message: '请再次输入密码', trigger: 'blur' }],
application_note: [{ max: 2000, message: '申请备注不能超过2000个字符', trigger: 'blur' }],
} }
async function handleLogin(): Promise<void> { async function handleLogin(): Promise<void> {
@ -243,8 +248,8 @@ async function submitApplyAccount(): Promise<void> {
applying.value = true applying.value = true
try { try {
await authApi.applyAccount(applyForm) const response: any = await authApi.applyAccount(applyForm)
ElMessage.success('申请提交成功,请联系或等待管理员添加权限') ElMessage.success(response?.message || '申请提交成功,请联系或等待管理员添加权限')
applyDialogVisible.value = false applyDialogVisible.value = false
Object.assign(applyForm, { Object.assign(applyForm, {
nickname: '', nickname: '',
@ -252,6 +257,7 @@ async function submitApplyAccount(): Promise<void> {
phone: '', phone: '',
password: '', password: '',
password_confirmation: '', password_confirmation: '',
application_note: '',
}) })
} catch (error: any) { } catch (error: any) {
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null

477
src/pages/TicketsPage.vue Normal file
View File

@ -0,0 +1,477 @@
<template>
<div class='tickets-page'>
<el-card>
<template #header>
<div class='page-header'>
<span class='font-700'>工单</span>
<div class='btn-gap-8'>
<el-button v-if='canUseTickets' type='primary' @click='openCreateTicket'>提交工单</el-button>
<el-button v-if='canManageTickets' @click='openCategoryDialog'>分类管理</el-button>
<el-button :loading='loading' @click='fetchList'>刷新</el-button>
</div>
</div>
</template>
<el-form :inline='true' :model='filters' class='mb-4'>
<el-form-item label='状态'>
<el-select v-model='filters.status' clearable placeholder='全部状态' class='filter-control'>
<el-option v-for='item in statusOptions' :key='item.value' :label='item.label' :value='item.value' />
</el-select>
</el-form-item>
<el-form-item label='分类'>
<el-cascader v-model='filters.category_id' clearable placeholder='全部分类' class='filter-control' :options='categoryOptions' :props='categoryCascaderProps' />
</el-form-item>
<el-form-item v-if='canViewAllTickets'>
<el-checkbox v-model='filters.mine'>只看我的</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type='primary' @click='fetchList'>查询</el-button>
</el-form-item>
</el-form>
<el-table :data='rows' v-loading='loading'>
<el-table-column prop='id' label='ID' width='70' />
<el-table-column prop='title' label='标题' min-width='180' />
<el-table-column label='分类' min-width='120'>
<template #default='{ row }'>{{ categoryLabel(row.ticket_category_id || row.category?.id) }}</template>
</el-table-column>
<el-table-column label='提交人' min-width='130'>
<template #default='{ row }'>{{ row.user?.nickname || '-' }}</template>
</el-table-column>
<el-table-column label='状态' width='110'>
<template #default='{ row }'><el-tag :type='statusTag(row.status)'>{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label='回复数' width='90'>
<template #default='{ row }'>{{ row.messages_count || 0 }}</template>
</el-table-column>
<el-table-column label='最后回复' min-width='170'>
<template #default='{ row }'>{{ formatDateTime(row.last_replied_at || row.updated_at) }}</template>
</el-table-column>
<el-table-column label='操作' width='190' fixed='right'>
<template #default='{ row }'>
<div class='btn-gap-8 btn-gap-8--nowrap'>
<el-button size='small' @click='openDetail(row)'>查看</el-button>
<el-button v-if='canManageTickets' size='small' type='warning' plain @click='openProcessDialog(row)'>处理</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
class='mt-4'
layout='total, sizes, prev, pager, next'
:total='total'
:current-page='page'
:page-size='perPage'
:page-sizes='[10, 20, 50, 100]'
@current-change='handlePageChange'
@size-change='handleSizeChange'
/>
</el-card>
<el-dialog v-model='ticketDialogVisible' title='提交工单' width='640px'>
<el-form :model='ticketForm' label-width='90px'>
<el-form-item label='分类'>
<el-cascader v-model='ticketForm.ticket_category_id' class='w-full' placeholder='请选择分类' :options='activeCategoryOptions' :props='categoryCascaderProps' />
</el-form-item>
<el-form-item label='标题'><el-input v-model='ticketForm.title' /></el-form-item>
<el-form-item label='内容'><el-input v-model='ticketForm.content' type='textarea' :rows='6' /></el-form-item>
</el-form>
<template #footer>
<el-button @click='ticketDialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='savingTicket' @click='submitTicket'>提交</el-button>
</template>
</el-dialog>
<el-dialog v-model='detailDialogVisible' :title='`工单详情 #${detail?.id || ""}`' width='760px' top='4vh'>
<div v-if='detail' class='ticket-detail'>
<div class='detail-title'>{{ detail.title }}</div>
<div class='detail-meta'>
<el-tag :type='statusTag(detail.status)'>{{ statusLabel(detail.status) }}</el-tag>
<span>{{ categoryLabel(detail.ticket_category_id || detail.category?.id) }}</span>
<span>{{ detail.user?.nickname || '-' }}</span>
</div>
<el-timeline class='mt-4'>
<el-timeline-item v-for='message in detail.messages || []' :key='message.id' :timestamp='formatDateTime(message.created_at)'>
<div class='message-box'>
<div class='message-sender'>{{ message.sender_type === 'admin' ? '管理员' : '用户' }}{{ message.user?.nickname || '-' }}</div>
<div class='message-content'>{{ message.content }}</div>
</div>
</el-timeline-item>
</el-timeline>
<el-input v-model='replyContent' type='textarea' :rows='4' placeholder='请输入回复内容' />
</div>
<template #footer>
<el-button @click='detailDialogVisible = false'>关闭</el-button>
<el-button v-if='canReplyTicket' type='primary' :loading='replying' @click='submitReply'>回复</el-button>
</template>
</el-dialog>
<el-dialog v-model='processDialogVisible' title='处理工单' width='520px'>
<el-form :model='processForm' label-width='90px'>
<el-form-item label='状态'>
<el-select v-model='processForm.status' class='w-full'>
<el-option v-for='item in statusOptions' :key='item.value' :label='item.label' :value='item.value' />
</el-select>
</el-form-item>
<el-form-item label='分类'>
<el-cascader v-model='processForm.ticket_category_id' clearable class='w-full' :options='categoryOptions' :props='categoryCascaderProps' />
</el-form-item>
</el-form>
<template #footer>
<el-button @click='processDialogVisible = false'>取消</el-button>
<el-button type='primary' :loading='processing' @click='submitProcess'>保存</el-button>
</template>
</el-dialog>
<el-dialog v-model='categoryDialogVisible' title='工单分类管理' width='760px'>
<div class='btn-gap-8 mb-3'>
<el-button type='primary' @click='openCategoryForm()'>新增分类</el-button>
<el-button :loading='categoryLoading' @click='fetchCategories'>刷新</el-button>
</div>
<el-table :data='categories' v-loading='categoryLoading'>
<el-table-column prop='name' label='名称' min-width='150' />
<el-table-column label='父级分类' min-width='150'>
<template #default='{ row }'>{{ categoryLabel(row.parent_id) }}</template>
</el-table-column>
<el-table-column prop='description' label='说明' min-width='220' />
<el-table-column label='启用' width='90'>
<template #default='{ row }'><el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label='操作' width='150'>
<template #default='{ row }'>
<div class='btn-gap-8 btn-gap-8--nowrap'>
<el-button size='small' @click='openCategoryForm(row)'>编辑</el-button>
<el-button size='small' type='danger' @click='removeCategory(row)'>删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog v-model='categoryFormVisible' :title='categoryForm.id ? "编辑分类" : "新增分类"' width='520px'>
<el-form :model='categoryForm' label-width='80px'>
<el-form-item label='父级分类'>
<el-cascader v-model='categoryForm.parent_id' clearable class='w-full' placeholder='顶级分类' :options='editableCategoryOptions' :props='categoryCascaderProps' />
</el-form-item>
<el-form-item label='名称'><el-input v-model='categoryForm.name' /></el-form-item>
<el-form-item label='说明'><el-input v-model='categoryForm.description' /></el-form-item>
<el-form-item label='启用'><el-switch v-model='categoryForm.is_active' /></el-form-item>
</el-form>
<template #footer>
<el-button @click='categoryFormVisible = false'>取消</el-button>
<el-button type='primary' :loading='categorySaving' @click='submitCategory'>保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang='ts'>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ticketsApi } from '@/api/tickets'
import { formatDateTime } from '@/composables/datetime'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { hasPermission } = authStore
const canUseTickets = computed(() => hasPermission('tickets.use'))
const canViewAllTickets = computed(() => hasPermission('platform.tickets.view') || hasPermission('platform.tickets.manage'))
const canManageTickets = computed(() => hasPermission('platform.tickets.manage'))
const canReplyTicket = computed(() => {
if (!detail.value) {
return false
}
return canManageTickets.value || (canUseTickets.value && Number(detail.value.user_id) === Number(authStore.user?.id))
})
const loading = ref(false)
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const perPage = ref(20)
const filters = reactive<any>({ status: '', category_id: null, mine: false })
const categories = ref<any[]>([])
const categoryTree = ref<any[]>([])
const activeCategories = computed(() => categories.value.filter((item) => item.is_active))
const categoryCascaderProps = { value: 'id', label: 'name', checkStrictly: true, emitPath: false }
const categoryOptions = computed(() => buildCategoryOptions(categoryTree.value))
const activeCategoryOptions = computed(() => buildCategoryOptions(categoryTree.value, true))
const editableCategoryOptions = computed(() => buildCategoryOptions(categoryTree.value, false, categoryForm.id))
const statusOptions = [
{ label: '待处理', value: 'open' },
{ label: '处理中', value: 'processing' },
{ label: '已解决', value: 'resolved' },
{ label: '已关闭', value: 'closed' },
]
const ticketDialogVisible = ref(false)
const savingTicket = ref(false)
const ticketForm = reactive<any>({ ticket_category_id: null, title: '', content: '' })
const detailDialogVisible = ref(false)
const detail = ref<any | null>(null)
const replyContent = ref('')
const replying = ref(false)
const processDialogVisible = ref(false)
const processing = ref(false)
const processTarget = ref<any | null>(null)
const processForm = reactive<any>({ status: 'open', ticket_category_id: null })
const categoryDialogVisible = ref(false)
const categoryLoading = ref(false)
const categoryFormVisible = ref(false)
const categorySaving = ref(false)
const categoryForm = reactive<any>({ id: null, parent_id: null, name: '', description: '', is_active: true })
async function fetchCategories(activeOnly = false): Promise<void> {
categoryLoading.value = true
try {
const response: any = await ticketsApi.categories(activeOnly ? { active_only: 1 } : {})
categories.value = response.data || []
categoryTree.value = response.tree || []
} finally {
categoryLoading.value = false
}
}
async function fetchList(): Promise<void> {
loading.value = true
try {
const response: any = await ticketsApi.list({
page: page.value,
per_page: perPage.value,
status: filters.status || undefined,
category_id: selectedCategoryId(filters.category_id) || undefined,
mine: filters.mine ? 1 : undefined,
})
rows.value = response.data.data || []
total.value = response.data.total || 0
} finally {
loading.value = false
}
}
function handlePageChange(nextPage: number): void {
page.value = nextPage
fetchList()
}
function handleSizeChange(nextSize: number): void {
perPage.value = nextSize
page.value = 1
fetchList()
}
function openCreateTicket(): void {
Object.assign(ticketForm, { ticket_category_id: activeCategories.value[0]?.id || null, title: '', content: '' })
ticketDialogVisible.value = true
}
async function submitTicket(): Promise<void> {
const ticketCategoryId = selectedCategoryId(ticketForm.ticket_category_id)
if (!ticketCategoryId || !ticketForm.title?.trim() || !ticketForm.content?.trim()) {
ElMessage.warning('请完整填写工单信息')
return
}
savingTicket.value = true
try {
await ticketsApi.create({ ...ticketForm, ticket_category_id: ticketCategoryId })
ElMessage.success('工单已提交')
ticketDialogVisible.value = false
await fetchList()
} catch (error: any) {
ElMessage.error(firstError(error) || '提交工单失败')
} finally {
savingTicket.value = false
}
}
async function openDetail(row: any): Promise<void> {
const response: any = await ticketsApi.detail(row.id)
detail.value = response.data
replyContent.value = ''
detailDialogVisible.value = true
}
async function submitReply(): Promise<void> {
if (!detail.value?.id || !replyContent.value.trim()) {
ElMessage.warning('请输入回复内容')
return
}
replying.value = true
try {
await ticketsApi.reply(detail.value.id, replyContent.value)
ElMessage.success('回复已提交')
await openDetail(detail.value)
await fetchList()
} catch (error: any) {
ElMessage.error(firstError(error) || '回复失败')
} finally {
replying.value = false
}
}
function openProcessDialog(row: any): void {
processTarget.value = row
Object.assign(processForm, { status: row.status, ticket_category_id: row.ticket_category_id || row.category?.id || null })
processDialogVisible.value = true
}
async function submitProcess(): Promise<void> {
if (!processTarget.value?.id) {
return
}
processing.value = true
try {
await ticketsApi.update(processTarget.value.id, { ...processForm, ticket_category_id: selectedCategoryId(processForm.ticket_category_id) })
ElMessage.success('工单已更新')
processDialogVisible.value = false
await fetchList()
} catch (error: any) {
ElMessage.error(firstError(error) || '处理失败')
} finally {
processing.value = false
}
}
async function openCategoryDialog(): Promise<void> {
categoryDialogVisible.value = true
await fetchCategories()
}
function openCategoryForm(row?: any): void {
Object.assign(categoryForm, {
id: row?.id || null,
parent_id: row?.parent_id || null,
name: row?.name || '',
description: row?.description || '',
is_active: typeof row?.is_active === 'boolean' ? row.is_active : true,
})
categoryFormVisible.value = true
}
async function submitCategory(): Promise<void> {
if (!categoryForm.name?.trim()) {
ElMessage.warning('请输入分类名称')
return
}
categorySaving.value = true
try {
const payload = {
parent_id: selectedCategoryId(categoryForm.parent_id),
name: categoryForm.name,
description: categoryForm.description || '',
is_active: Boolean(categoryForm.is_active),
}
if (categoryForm.id) {
await ticketsApi.updateCategory(categoryForm.id, payload)
} else {
await ticketsApi.createCategory(payload)
}
ElMessage.success('分类已保存')
categoryFormVisible.value = false
await fetchCategories()
} catch (error: any) {
ElMessage.error(firstError(error) || '保存分类失败')
} finally {
categorySaving.value = false
}
}
function selectedCategoryId(value: any): number | null {
if (Array.isArray(value)) {
return value.length > 0 ? Number(value[value.length - 1]) : null
}
return value ? Number(value) : null
}
function buildCategoryOptions(items: any[], activeOnly = false, excludedId: number | null = null): any[] {
return items
.filter((item) => {
if (Number(item.id) === Number(excludedId)) {
return false
}
return !activeOnly || item.is_active
})
.map((item) => {
const children = buildCategoryOptions(item.children || [], activeOnly, excludedId)
return {
id: item.id,
name: item.name,
disabled: activeOnly && !item.is_active,
children: children.length > 0 ? children : undefined,
}
})
}
function categoryLabel(categoryId: number | string | null | undefined): string {
if (!categoryId) {
return '-'
}
const path = findCategoryPath(categoryTree.value, Number(categoryId))
return path.length > 0 ? path.join(' / ') : '-'
}
function findCategoryPath(items: any[], categoryId: number, parents: string[] = []): string[] {
for (const item of items) {
const nextPath = [...parents, item.name]
if (Number(item.id) === categoryId) {
return nextPath
}
const childPath = findCategoryPath(item.children || [], categoryId, nextPath)
if (childPath.length > 0) {
return childPath
}
}
return []
}
async function removeCategory(row: any): Promise<void> {
await ElMessageBox.confirm(`确认删除分类「${row.name}」吗?历史工单会保留但分类置空。`, '提示', { type: 'warning' })
await ticketsApi.removeCategory(row.id)
ElMessage.success('分类已删除')
await fetchCategories()
}
function statusLabel(status: string): string {
return statusOptions.find((item) => item.value === status)?.label || status
}
function statusTag(status: string): 'success' | 'warning' | 'info' | 'primary' {
if (status === 'open') {
return 'warning'
}
if (status === 'processing') {
return 'primary'
}
if (status === 'resolved') {
return 'success'
}
return 'info'
}
function firstError(error: any): string {
return error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : (error?.message || '')
}
onMounted(async () => {
await fetchCategories()
await fetchList()
})
</script>
<style scoped>
.tickets-page { display: flex; flex-direction: column; gap: 16px; }
.page-header { display: flex; align-items: center; justify-content: space-between; }
.filter-control { width: 180px; }
.ticket-detail { max-height: 62vh; overflow: auto; padding-right: 8px; }
.detail-title { font-size: 18px; font-weight: 700; color: #0f172a; }
.detail-meta { margin-top: 8px; display: flex; align-items: center; gap: 10px; color: #64748b; }
.message-box { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; background: #f8fafc; }
.message-sender { font-size: 12px; color: #64748b; margin-bottom: 6px; }
.message-content { white-space: pre-wrap; color: #0f172a; }
</style>

View File

@ -18,7 +18,15 @@
<el-table-column prop='email' label='邮箱' min-width='180' sortable='custom' /> <el-table-column prop='email' label='邮箱' min-width='180' sortable='custom' />
<el-table-column prop='phone' label='手机号' min-width='140' sortable='custom' /> <el-table-column prop='phone' label='手机号' min-width='140' sortable='custom' />
<el-table-column label='角色' min-width='160'> <el-table-column label='角色' min-width='160'>
<template #default='{ row }'>{{ (row.roles || []).map((r:any) => r.name).join(', ') || '-' }}</template> <template #default='{ row }'>
<span v-if='hasGuestApplicationNote(row)' class='role-note-wrap'>
<span>{{ roleLabel(row) }}</span>
<el-tooltip :content='row.application_note' placement='top' effect='dark'>
<span class='role-note-mark'>?</span>
</el-tooltip>
</span>
<span v-else>{{ roleLabel(row) }}</span>
</template>
</el-table-column> </el-table-column>
<el-table-column label='服务器账号' min-width='260'> <el-table-column label='服务器账号' min-width='260'>
<template #default='{ row }'>{{ serverBindingsLabel(row) }}</template> <template #default='{ row }'>{{ serverBindingsLabel(row) }}</template>
@ -489,6 +497,14 @@ function serverBindingsLabel(row: any): string {
.join('') .join('')
} }
function roleLabel(row: any): string {
return (row.roles || []).map((role: any) => role.name).join(', ') || '-'
}
function hasGuestApplicationNote(row: any): boolean {
return Boolean(String(row.application_note || '').trim()) && (row.roles || []).some((role: any) => role.name === 'guest')
}
function normalizeServerUsername(value: string): string { function normalizeServerUsername(value: string): string {
const raw = String(value || '').trim() const raw = String(value || '').trim()
const ascii = raw const ascii = raw
@ -658,4 +674,6 @@ onMounted(async () => {
:deep(.permission-panel .el-cascader-menu__list) { width: 100%; } :deep(.permission-panel .el-cascader-menu__list) { width: 100%; }
:deep(.permission-panel .el-cascader-node) { width: 100%; } :deep(.permission-panel .el-cascader-node) { width: 100%; }
:deep(.permission-panel .el-cascader-panel) { height: 100%; } :deep(.permission-panel .el-cascader-panel) { height: 100%; }
.role-note-wrap { display: inline-flex; align-items: center; gap: 4px; }
.role-note-mark { color: #111827; font-weight: 700; cursor: help; }
</style> </style>

View File

@ -25,6 +25,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'roles', component: () => import('@/pages/RolesPage.vue') }, { path: 'roles', component: () => import('@/pages/RolesPage.vue') },
{ path: 'permissions', component: () => import('@/pages/PermissionsPage.vue') }, { path: 'permissions', component: () => import('@/pages/PermissionsPage.vue') },
{ path: 'servers', component: () => import('@/pages/ServersPage.vue') }, { path: 'servers', component: () => import('@/pages/ServersPage.vue') },
{ path: 'tickets', component: () => import('@/pages/TicketsPage.vue') },
{ path: 'accounts', component: () => import('@/pages/AccountsPage.vue') }, { path: 'accounts', component: () => import('@/pages/AccountsPage.vue') },
{ path: 'logs', component: () => import('@/pages/LogsPage.vue') }, { path: 'logs', component: () => import('@/pages/LogsPage.vue') },
{ path: 'oauth-clients', component: () => import('@/pages/OauthClientsPage.vue') }, { path: 'oauth-clients', component: () => import('@/pages/OauthClientsPage.vue') },