feat(工单): 完成工单系统的编写
This commit is contained in:
parent
00543543da
commit
c6be29d756
4
components.d.ts
vendored
4
components.d.ts
vendored
@ -15,6 +15,7 @@ declare module 'vue' {
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCascaderPanel: typeof import('element-plus/es')['ElCascaderPanel']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
@ -46,6 +47,9 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
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']
|
||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
||||
@ -18,6 +18,7 @@ interface ApplyAccountPayload {
|
||||
phone: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
application_note?: string
|
||||
}
|
||||
|
||||
interface MeData {
|
||||
|
||||
31
src/api/tickets.ts
Normal file
31
src/api/tickets.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
@ -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.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='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.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>
|
||||
@ -62,6 +63,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
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 siteTitle = computed(() => (siteTitleConfig.value || DEFAULT_SITE_TITLE).trim() || DEFAULT_SITE_TITLE)
|
||||
|
||||
|
||||
@ -63,6 +63,9 @@
|
||||
<el-form-item label='确认密码' prop='password_confirmation'>
|
||||
<el-input v-model='applyForm.password_confirmation' type='password' show-password placeholder='请再次输入密码' />
|
||||
</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>
|
||||
<template #footer>
|
||||
<el-button @click='applyDialogVisible = false'>取消</el-button>
|
||||
@ -101,6 +104,7 @@ const applyForm = reactive({
|
||||
phone: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
application_note: '',
|
||||
})
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
@ -153,6 +157,7 @@ const applyRules: FormRules = {
|
||||
],
|
||||
password: [{ 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> {
|
||||
@ -243,8 +248,8 @@ async function submitApplyAccount(): Promise<void> {
|
||||
|
||||
applying.value = true
|
||||
try {
|
||||
await authApi.applyAccount(applyForm)
|
||||
ElMessage.success('申请提交成功,请联系或等待管理员添加权限')
|
||||
const response: any = await authApi.applyAccount(applyForm)
|
||||
ElMessage.success(response?.message || '申请提交成功,请联系或等待管理员添加权限')
|
||||
applyDialogVisible.value = false
|
||||
Object.assign(applyForm, {
|
||||
nickname: '',
|
||||
@ -252,6 +257,7 @@ async function submitApplyAccount(): Promise<void> {
|
||||
phone: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
application_note: '',
|
||||
})
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
|
||||
477
src/pages/TicketsPage.vue
Normal file
477
src/pages/TicketsPage.vue
Normal 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>
|
||||
@ -18,7 +18,15 @@
|
||||
<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 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 label='服务器账号' min-width='260'>
|
||||
<template #default='{ row }'>{{ serverBindingsLabel(row) }}</template>
|
||||
@ -489,6 +497,14 @@ function serverBindingsLabel(row: any): string {
|
||||
.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 {
|
||||
const raw = String(value || '').trim()
|
||||
const ascii = raw
|
||||
@ -658,4 +674,6 @@ onMounted(async () => {
|
||||
:deep(.permission-panel .el-cascader-menu__list) { width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-node) { width: 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>
|
||||
|
||||
@ -25,6 +25,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'roles', component: () => import('@/pages/RolesPage.vue') },
|
||||
{ path: 'permissions', component: () => import('@/pages/PermissionsPage.vue') },
|
||||
{ path: 'servers', component: () => import('@/pages/ServersPage.vue') },
|
||||
{ path: 'tickets', component: () => import('@/pages/TicketsPage.vue') },
|
||||
{ path: 'accounts', component: () => import('@/pages/AccountsPage.vue') },
|
||||
{ path: 'logs', component: () => import('@/pages/LogsPage.vue') },
|
||||
{ path: 'oauth-clients', component: () => import('@/pages/OauthClientsPage.vue') },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user