10 详情页与状态流转:跑通真实业务闭环
10 详情页与状态流转:跑通真实业务闭环
本章你会做出什么
这一章是项目核心。工单详情会展示基本信息、评论与状态时间线,并根据当前身份和状态提供编辑、分配、提交解决、确认关闭、退回和取消动作。
先理解三个概念
1. 状态动作而不是目标状态
前端提交 SUBMIT_RESOLUTION,而不是直接说“请变成 CLOSED”。后端根据当前状态、动作和用户判断合法的新状态,从而防止乱跳。
2. 数据归属校验
即使角色有 ticket:confirm,也只能确认自己创建的工单。处理人只能处理分给自己的工单;主管和管理员可按业务规则处理全部工单。
3. 流转日志
每次创建、分配或变更状态都追加日志,不覆盖历史记录。时间线因此能回答“谁在什么时候做了什么”。
本章最终目录变化
server/src/
services/ticket-service.ts
store/store.ts # 增加详情、更新、评论、日志方法
store/memory-store.ts
app.ts # 增加详情、编辑、分配、流转、评论路由
client/src/
api/modules.ts # 增加 detail/update/assign/transition/comment
views/tickets/TicketDetailView.vue
一步一步操作
第 1 步:给存储层增加日志和评论
在 TicketStore 添加方法:
findTicketById(id: number): Promise<Ticket | undefined>
updateTicket(id: number, input: Partial<Ticket>): Promise<Ticket | undefined>
listComments(ticketId: number): Promise<TicketComment[]>
addComment(input: Omit<TicketComment, 'id'>): Promise<TicketComment>
listLogs(ticketId: number): Promise<TicketLog[]>
addLog(input: Omit<TicketLog, 'id'>): Promise<TicketLog>
内存 Store 新增 comments 与 logs 数组。创建工单后马上写日志:
await store.addLog({
ticketId: ticket.id,
fromStatus: null,
toStatus: 'PENDING',
operatorId: user.id,
remark: '提交工单',
createdAt: now
})
第 2 步:由 Service 统一业务判断
路由负责解析参数,TicketService 负责“这个人能否在这个状态下做该动作”。不要将状态判断散落在多条路由中。
第 3 步:接入五条业务接口
GET /api/v1/tickets/:id
PATCH /api/v1/tickets/:id
POST /api/v1/tickets/:id/assign
POST /api/v1/tickets/:id/transition
POST /api/v1/tickets/:id/comments
第 4 步:前端详情根据状态展示动作
PENDING且本人创建:编辑、取消。PENDING且有ticket:assign:分配。PROCESSING且有处理资格:提交解决。WAITING_CONFIRM且本人创建:确认关闭或退回。
关键文件完整代码
server/src/services/ticket-service.ts(替换完整文件)
import { STATUS_LABELS } from '../constants.js'
import { AppError } from '../errors.js'
import type { TicketStore } from '../store/store.js'
import type { AuthUser, DashboardData, Ticket, TicketAction, TicketPriority, TicketQuery, TicketStatus } from '../types.js'
import { dueAtForPriority, isOverdue, isoNow } from '../utils.js'
export class TicketService {
constructor(private readonly store: TicketStore) {}
async list(user: AuthUser, input: Omit<TicketQuery, 'scope' | 'scopeUserId'> & { mine?: boolean }) {
return this.store.listTickets({ ...input, ...this.getScope(user, input.mine) })
}
async detail(user: AuthUser, id: number) {
const ticket = await this.getVisibleTicket(user, id)
const [comments, logs] = await Promise.all([this.store.listComments(id), this.store.listLogs(id)])
return { ...ticket, comments, logs }
}
async create(user: AuthUser, input: { title: string; description: string; categoryId: number; priority: TicketPriority }) {
const now = isoNow()
const ticket = await this.store.createTicket({
...input,
status: 'PENDING',
creatorId: user.id,
assigneeId: null,
dueAt: dueAtForPriority(input.priority, new Date(now)),
acceptedAt: null,
resolvedAt: null,
closedAt: null,
createdAt: now,
updatedAt: now
})
await this.store.addLog({
ticketId: ticket.id,
fromStatus: null,
toStatus: 'PENDING',
operatorId: user.id,
remark: '提交工单',
createdAt: now
})
return ticket
}
async updatePending(
user: AuthUser,
id: number,
input: Partial<Pick<Ticket, 'title' | 'description' | 'categoryId' | 'priority'>>
) {
const ticket = await this.getVisibleTicket(user, id)
if (ticket.creatorId !== user.id || ticket.status !== 'PENDING') {
throw new AppError(400, '仅创建人可以编辑待受理工单')
}
const dueAt = input.priority ? dueAtForPriority(input.priority, new Date(ticket.createdAt)) : ticket.dueAt
return this.store.updateTicket(id, { ...input, dueAt })
}
async assign(user: AuthUser, id: number, assigneeId: number, remark?: string) {
const ticket = await this.getVisibleTicket(user, id)
if (ticket.status !== 'PENDING') throw new AppError(400, '只有待受理工单可以分配')
const assignee = await this.store.findUserById(assigneeId)
if (!assignee || !assignee.status) throw new AppError(400, '处理人员不存在或不可用')
const role = (await this.store.listRoles()).find((item) => item.id === assignee.roleId)
if (!role || !['AGENT', 'SUPERVISOR', 'ADMIN'].includes(role.code)) {
throw new AppError(400, '请选择可处理工单的人员')
}
const updated = await this.store.updateTicket(id, {
assigneeId,
status: 'PROCESSING',
acceptedAt: isoNow()
})
await this.logTransition(ticket, 'PROCESSING', user.id, remark || `分配给${assignee.name}`)
return updated
}
async transition(user: AuthUser, id: number, action: TicketAction, remark = '') {
const ticket = await this.getVisibleTicket(user, id)
let status: TicketStatus
const updates: Partial<Ticket> = {}
if (action === 'SUBMIT_RESOLUTION') {
if (ticket.status !== 'PROCESSING' || !this.canHandle(user, ticket)) {
throw new AppError(400, '当前工单不能提交解决结果')
}
if (!remark.trim()) throw new AppError(400, '请填写处理结果')
status = 'WAITING_CONFIRM'
updates.resolvedAt = isoNow()
} else if (action === 'CONFIRM_CLOSE') {
if (
!user.permissionCodes.includes('ticket:confirm') ||
ticket.status !== 'WAITING_CONFIRM' ||
ticket.creatorId !== user.id
) {
throw new AppError(400, '仅创建人可以确认待确认工单')
}
status = 'CLOSED'
updates.closedAt = isoNow()
} else if (action === 'REOPEN') {
if (
!user.permissionCodes.includes('ticket:confirm') ||
ticket.status !== 'WAITING_CONFIRM' ||
ticket.creatorId !== user.id
) {
throw new AppError(400, '仅创建人可以退回待确认工单')
}
if (!remark.trim()) throw new AppError(400, '请填写退回原因')
status = 'PROCESSING'
updates.resolvedAt = null
} else {
if (
action !== 'CANCEL' ||
!user.permissionCodes.includes('ticket:cancel') ||
ticket.status !== 'PENDING' ||
ticket.creatorId !== user.id
) {
throw new AppError(400, '仅创建人可以取消待受理工单')
}
status = 'CANCELLED'
}
const updated = await this.store.updateTicket(id, { ...updates, status })
await this.logTransition(ticket, status, user.id, remark || STATUS_LABELS[status])
return updated
}
async addComment(user: AuthUser, id: number, content: string) {
await this.getVisibleTicket(user, id)
return this.store.addComment({ ticketId: id, userId: user.id, content, createdAt: isoNow() })
}
async dashboard(user: AuthUser, range: 7 | 30): Promise<DashboardData> {
const result = await this.store.listTickets({ page: 1, pageSize: 10000, ...this.getScope(user, false) })
const from = Date.now() - (range - 1) * 24 * 60 * 60 * 1000
const tickets = result.list.filter((ticket) => new Date(ticket.createdAt).getTime() >= from)
const count = (status: TicketStatus) => tickets.filter((ticket) => ticket.status === status).length
const categoryMap = new Map<string, number>()
tickets.forEach((ticket) =>
categoryMap.set(ticket.categoryName ?? '其他', (categoryMap.get(ticket.categoryName ?? '其他') ?? 0) + 1)
)
const dailyTrend = Array.from({ length: range }, (_, offset) => {
const date = new Date()
date.setDate(date.getDate() - (range - offset - 1))
const key = date.toISOString().slice(0, 10)
return {
date: key.slice(5),
created: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === key).length,
closed: tickets.filter((ticket) => ticket.closedAt?.slice(0, 10) === key).length
}
})
return {
cards: {
total: tickets.length,
pending: count('PENDING'),
processing: count('PROCESSING'),
waitingConfirm: count('WAITING_CONFIRM'),
closed: count('CLOSED'),
overdue: tickets.filter(isOverdue).length
},
statusDistribution: (['PENDING', 'PROCESSING', 'WAITING_CONFIRM', 'CLOSED', 'CANCELLED'] as TicketStatus[]).map(
(status) => ({ name: STATUS_LABELS[status], value: count(status) })
),
categoryRanking: Array.from(categoryMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((left, right) => right.value - left.value),
dailyTrend,
recentTickets: tickets.slice(0, 5)
}
}
private async getVisibleTicket(user: AuthUser, id: number) {
const ticket = await this.store.findTicketById(id)
if (!ticket) throw new AppError(404, '工单不存在')
if (
user.permissionCodes.includes('ticket:view_all') ||
(user.permissionCodes.includes('ticket:view_own') && ticket.creatorId === user.id) ||
(user.permissionCodes.includes('ticket:view_assigned') && ticket.assigneeId === user.id)
) {
return ticket
}
throw new AppError(403, '没有查看此工单的权限')
}
private getScope(user: AuthUser, mine = false): Pick<TicketQuery, 'scope' | 'scopeUserId'> {
if (mine) {
return user.permissionCodes.includes('ticket:view_own') ? { scope: 'created', scopeUserId: user.id } : { scope: 'none' }
}
if (user.permissionCodes.includes('ticket:view_all')) return { scope: 'all' }
if (user.permissionCodes.includes('ticket:view_assigned')) return { scope: 'assigned', scopeUserId: user.id }
if (user.permissionCodes.includes('ticket:view_own')) return { scope: 'created', scopeUserId: user.id }
return { scope: 'none' }
}
private canHandle(user: AuthUser, ticket: Ticket) {
return user.permissionCodes.includes('ticket:handle') && (ticket.assigneeId === user.id || user.roleCode !== 'AGENT')
}
private async logTransition(ticket: Ticket, toStatus: TicketStatus, operatorId: number, remark: string) {
await this.store.addLog({
ticketId: ticket.id,
fromStatus: ticket.status,
toStatus,
operatorId,
remark,
createdAt: isoNow()
})
}
}
第 3 章已经将 DashboardData、TicketQuery 与 TicketFormValue 放入后端类型文件。本章还要将 server/src/utils.ts 替换为以下完整文件,供 SLA 与请求编号共同复用:
import { randomUUID } from 'node:crypto'
import { SLA_HOURS } from './constants.js'
import type { Ticket, TicketPriority } from './types.js'
export const isoNow = () => new Date().toISOString()
export const dueAtForPriority = (priority: TicketPriority, createdAt = new Date()) =>
new Date(createdAt.getTime() + SLA_HOURS[priority] * 60 * 60 * 1000).toISOString()
export const requestId = () => `req_${randomUUID().replaceAll('-', '').slice(0, 16)}`
export const isOverdue = (ticket: Ticket) =>
!['CLOSED', 'CANCELLED'].includes(ticket.status) && new Date(ticket.dueAt).getTime() < Date.now()
server/src/app.ts 的工单路由完整段落
const tickets = new TicketService(store)
const ticketStatuses = ['PENDING', 'PROCESSING', 'WAITING_CONFIRM', 'CLOSED', 'CANCELLED'] as const
const priorities = ['LOW', 'MEDIUM', 'HIGH'] as const
const actions = ['SUBMIT_RESOLUTION', 'CONFIRM_CLOSE', 'REOPEN', 'CANCEL'] as const
const parseId = (value: string | string[]) => {
const raw = Array.isArray(value) ? value[0] : value
const id = Number(raw)
if (!Number.isInteger(id) || id <= 0) throw new AppError(400, '无效的资源编号')
return id
}
app.get('/api/v1/tickets', authenticated, async (req, res) => {
const parsed = z
.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(10),
keyword: z.string().optional(),
status: z.enum(ticketStatuses).optional(),
priority: z.enum(priorities).optional(),
assigneeId: z.coerce.number().int().positive().optional(),
mine: z.enum(['true', 'false']).optional()
})
.parse(req.query)
success(res, await tickets.list(req.currentUser!, { ...parsed, mine: parsed.mine === 'true' }))
})
app.post('/api/v1/tickets', authenticated, requirePermission('ticket:create'), async (req, res) => {
const input = z
.object({
title: z.string().trim().min(3).max(100),
categoryId: z.number().int().positive(),
priority: z.enum(priorities),
description: z.string().trim().min(5).max(2000)
})
.parse(req.body)
success(res, await tickets.create(req.currentUser!, input), '创建成功')
})
app.get('/api/v1/tickets/:id', authenticated, async (req, res) => {
success(res, await tickets.detail(req.currentUser!, parseId(req.params.id)))
})
app.patch('/api/v1/tickets/:id', authenticated, async (req, res) => {
const input = z
.object({
title: z.string().trim().min(3).max(100).optional(),
categoryId: z.number().int().positive().optional(),
priority: z.enum(priorities).optional(),
description: z.string().trim().min(5).max(2000).optional()
})
.parse(req.body)
success(res, await tickets.updatePending(req.currentUser!, parseId(req.params.id), input), '更新成功')
})
app.post('/api/v1/tickets/:id/assign', authenticated, requirePermission('ticket:assign'), async (req, res) => {
const input = z.object({ assigneeId: z.number().int().positive(), remark: z.string().max(500).optional() }).parse(req.body)
success(res, await tickets.assign(req.currentUser!, parseId(req.params.id), input.assigneeId, input.remark), '分配成功')
})
app.post('/api/v1/tickets/:id/transition', authenticated, async (req, res) => {
const input = z.object({ action: z.enum(actions), remark: z.string().max(500).optional() }).parse(req.body)
success(res, await tickets.transition(req.currentUser!, parseId(req.params.id), input.action, input.remark), '状态更新成功')
})
app.post('/api/v1/tickets/:id/comments', authenticated, requirePermission('ticket:comment'), async (req, res) => {
const input = z.object({ content: z.string().trim().min(1).max(1000) }).parse(req.body)
success(res, await tickets.addComment(req.currentUser!, parseId(req.params.id), input.content), '评论成功')
})
这些路由和第 5 章的登录路由处于同一个 createApp() 中;z 来自 zod,TicketService 从本章文件导入。
server/src/app.ts 的处理人接口完整段落
app.get('/api/v1/users/assignees', authenticated, requirePermission('ticket:assign'), async (_req, res) => {
const [agents, supervisors] = await Promise.all([
store.listUsers({ page: 1, pageSize: 100, roleCode: 'AGENT', status: 1 }),
store.listUsers({ page: 1, pageSize: 100, roleCode: 'SUPERVISOR', status: 1 })
])
success(
res,
[...agents.list, ...supervisors.list].map(({ passwordHash: _passwordHash, ...user }) => user)
)
})
client/src/api/modules.ts 的工单详情方法
// ticketApi 中增加:
detail: (id: number) => api.get<TicketDetail>(`/tickets/${id}`),
update: (id: number, input: TicketFormValue) => api.patch<Ticket>(`/tickets/${id}`, input),
assign: (id: number, input: { assigneeId: number; remark?: string }) =>
api.post<Ticket>(`/tickets/${id}/assign`, input),
transition: (id: number, action: TicketAction, remark?: string) =>
api.post<Ticket>(`/tickets/${id}/transition`, { action, remark }),
comment: (id: number, content: string) =>
api.post(`/tickets/${id}/comments`, { content }),
// commonApi 中增加:
assignees: () => api.get<Array<{ id: number; name: string; username: string }>>('/users/assignees'),
client/src/views/tickets/TicketDetailView.vue(替换完整文件)
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { commonApi, ticketApi } from '../../api/modules'
import PriorityTag from '../../components/PriorityTag.vue'
import SlaTag from '../../components/SlaTag.vue'
import StatusTag from '../../components/StatusTag.vue'
import TicketForm from '../../components/TicketForm.vue'
import { useAuthStore } from '../../stores/auth'
import type { TicketAction, TicketDetail, TicketFormValue } from '../../types'
import { formatDate, statusLabel } from '../../utils/business'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const ticket = ref<TicketDetail>()
const assignees = ref<Array<{ id: number; name: string }>>([])
const assignVisible = ref(false)
const editVisible = ref(false)
const resolutionVisible = ref(false)
const reopenVisible = ref(false)
const editing = ref(false)
const assignForm = reactive<{ assigneeId?: number; remark: string }>({ remark: '' })
const actionRemark = ref('')
const comment = ref('')
const commenting = ref(false)
const isCreator = computed(() => ticket.value?.creatorId === auth.user?.id)
const canAssign = computed(() => ticket.value?.status === 'PENDING' && auth.hasPermission('ticket:assign'))
const canSubmit = computed(
() =>
ticket.value?.status === 'PROCESSING' &&
auth.hasPermission('ticket:handle') &&
(ticket.value?.assigneeId === auth.user?.id || auth.user?.roleCode !== 'AGENT')
)
const canConfirm = computed(
() => ticket.value?.status === 'WAITING_CONFIRM' && isCreator.value && auth.hasPermission('ticket:confirm')
)
const canCancel = computed(() => ticket.value?.status === 'PENDING' && isCreator.value && auth.hasPermission('ticket:cancel'))
const canEdit = computed(() => ticket.value?.status === 'PENDING' && isCreator.value)
const load = async () => {
loading.value = true
try {
ticket.value = await ticketApi.detail(Number(route.params.id))
} finally {
loading.value = false
}
}
const openAssign = async () => {
if (!assignees.value.length) assignees.value = await commonApi.assignees()
assignVisible.value = true
}
const assign = async () => {
if (!ticket.value || !assignForm.assigneeId) return
await ticketApi.assign(ticket.value.id, { assigneeId: assignForm.assigneeId, remark: assignForm.remark })
ElMessage.success('工单已分配并进入处理中')
assignVisible.value = false
await load()
}
const edit = async (input: TicketFormValue) => {
if (!ticket.value) return
editing.value = true
try {
await ticketApi.update(ticket.value.id, input)
editVisible.value = false
ElMessage.success('工单信息已更新')
await load()
} finally {
editing.value = false
}
}
const transition = async (action: TicketAction, remark?: string) => {
if (!ticket.value) return
await ticketApi.transition(ticket.value.id, action, remark)
ElMessage.success('工单状态已更新')
resolutionVisible.value = false
reopenVisible.value = false
actionRemark.value = ''
await load()
}
const cancel = async () => {
await ElMessageBox.confirm('取消后工单将不再继续处理,确认取消吗?', '取消工单', { type: 'warning' })
await transition('CANCEL')
}
const sendComment = async () => {
if (!ticket.value || !comment.value.trim()) return
commenting.value = true
try {
await ticketApi.comment(ticket.value.id, comment.value.trim())
comment.value = ''
ElMessage.success('回复已发布')
await load()
} finally {
commenting.value = false
}
}
onMounted(load)
</script>
<template>
<div v-loading="loading" class="page-container">
<div v-if="ticket">
<div class="back" @click="router.back()">返回工单列表</div>
<el-card shadow="never" class="header-card content-card">
<div class="ticket-head">
<div>
<div class="number">{{ ticket.ticketNo }}</div>
<h1 class="page-title">{{ ticket.title }}</h1>
<div class="tags">
<StatusTag :status="ticket.status" />
<PriorityTag :priority="ticket.priority" />
<SlaTag :ticket="ticket" />
</div>
</div>
<div class="actions">
<el-button v-if="canEdit" @click="editVisible = true">编辑工单</el-button>
<el-button v-if="canCancel" @click="cancel">取消工单</el-button>
<el-button v-if="canAssign" type="primary" @click="openAssign">分配负责人</el-button>
<el-button v-if="canSubmit" type="primary" @click="resolutionVisible = true">提交处理结果</el-button>
<el-button v-if="canConfirm" @click="reopenVisible = true">退回处理</el-button>
<el-button v-if="canConfirm" type="success" @click="transition('CONFIRM_CLOSE')">确认关闭</el-button>
</div>
</div>
</el-card>
<div class="detail-grid">
<div>
<el-card shadow="never" class="content-card description">
<template #header>问题描述</template>
<p>{{ ticket.description }}</p>
</el-card>
<el-card shadow="never" class="content-card comments">
<template #header>沟通记录</template>
<el-empty v-if="!ticket.comments.length" description="暂时没有回复" :image-size="64" />
<div v-for="item in ticket.comments" :key="item.id" class="comment">
<div>
<strong>{{ item.userName }}</strong
><span>{{ formatDate(item.createdAt) }}</span>
</div>
<p>{{ item.content }}</p>
</div>
<el-input v-model="comment" type="textarea" :rows="3" placeholder="补充问题或沟通处理情况" />
<el-button v-permission="'ticket:comment'" type="primary" :loading="commenting" class="reply" @click="sendComment"
>发布回复</el-button
>
</el-card>
</div>
<div>
<el-card shadow="never" class="content-card info">
<template #header>工单信息</template>
<el-descriptions :column="1">
<el-descriptions-item label="创建人">{{ ticket.creatorName }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ ticket.assigneeName || '待分配' }}</el-descriptions-item>
<el-descriptions-item label="问题分类">{{ ticket.categoryName }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDate(ticket.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="SLA 截止">{{ formatDate(ticket.dueAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="content-card timeline">
<template #header>处理时间线</template>
<el-timeline>
<el-timeline-item v-for="log in ticket.logs" :key="log.id" :timestamp="formatDate(log.createdAt)" placement="top">
<strong>{{ statusLabel(log.toStatus) }}</strong>
<p>{{ log.operatorName }}:{{ log.remark }}</p>
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</div>
</div>
<el-dialog v-model="editVisible" title="编辑待受理工单" width="840px">
<TicketForm
v-if="ticket"
:initial-value="ticket"
:submitting="editing"
submit-label="保存修改"
@cancel="editVisible = false"
@submit="edit"
/>
</el-dialog>
<el-dialog v-model="assignVisible" title="分配处理人" width="440px">
<el-form label-position="top">
<el-form-item label="负责人" required>
<el-select v-model="assignForm.assigneeId" placeholder="请选择处理人员" style="width: 100%">
<el-option v-for="person in assignees" :key="person.id" :label="person.name" :value="person.id" />
</el-select>
</el-form-item>
<el-form-item label="分配备注">
<el-input v-model="assignForm.remark" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer
><el-button @click="assignVisible = false">取消</el-button
><el-button type="primary" @click="assign">确认分配</el-button></template
>
</el-dialog>
<el-dialog v-model="resolutionVisible" title="提交处理结果" width="500px">
<el-input v-model="actionRemark" type="textarea" :rows="5" placeholder="说明处理方式与验证结果" />
<template #footer>
<el-button @click="resolutionVisible = false">取消</el-button>
<el-button type="primary" @click="transition('SUBMIT_RESOLUTION', actionRemark)">提交结果</el-button>
</template>
</el-dialog>
<el-dialog v-model="reopenVisible" title="退回处理" width="500px">
<el-input v-model="actionRemark" type="textarea" :rows="4" placeholder="请说明仍需处理的问题" />
<template #footer>
<el-button @click="reopenVisible = false">取消</el-button>
<el-button type="warning" @click="transition('REOPEN', actionRemark)">确认退回</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.back {
display: inline-block;
margin-bottom: 14px;
color: var(--sd-brand);
cursor: pointer;
}
.header-card {
margin-bottom: 17px;
}
.ticket-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.number {
font-size: 13px;
color: var(--sd-muted);
margin-bottom: 7px;
}
.tags {
display: flex;
gap: 9px;
margin-top: 13px;
}
.actions {
display: flex;
gap: 9px;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(500px, 1fr) 380px;
gap: 16px;
}
.description,
.comments,
.info {
margin-bottom: 16px;
}
.description p {
white-space: pre-line;
min-height: 80px;
line-height: 1.7;
}
.comment {
border-bottom: 1px solid var(--sd-border);
padding: 0 0 13px;
margin-bottom: 16px;
}
.comment span {
margin-left: 12px;
font-size: 12px;
color: var(--sd-muted);
}
.comment p {
margin: 8px 0 0;
}
.reply {
margin-top: 12px;
float: right;
}
.comments :deep(.el-card__body) {
overflow: hidden;
}
.timeline p {
color: var(--sd-muted);
margin: 6px 0 0;
font-size: 13px;
}
</style>
处理人接口已在前面的完整路由段落给出。它只返回启用的处理人员,因此分配弹窗不会选择到停用账号或普通提单用户。
启动并验证
依次验证一张新工单:
user登录并创建工单,详情显示“编辑”和“取消工单”。- 修改优先级,刷新后确认 SLA 截止时间随创建时间重新计算。
supervisor登录并分配给agent,状态应变为“处理中”,编辑入口消失。agent登录并提交解决,状态应变为“待确认”。user登录并确认关闭,状态应变为“已关闭”。- 时间线应包含提交、分配、处理完成、确认关闭四次记录。
常见报错与原因
| 现象 | 原因 | 修正 |
|---|---|---|
| 普通用户能查看他人详情 | 详情接口忘记调用 mayRead |
在返回数据前校验读取范围 |
| 工单从待受理直接关闭 | 后端按目标状态修改而非按动作判断 | 集中使用 transition() 的规则 |
| 修改高优先级后 SLA 不变 | 编辑接口未重算 dueAt |
由创建时间和新优先级重新计算 |
| 时间线只有当前状态 | 更新工单但未追加日志 | 每个创建/流转动作调用 addLog |
本章完成清单
- 创建、分配、处理、确认关闭可完整演示。
- 待受理编辑只对创建人展示且流转后消失。
- 评论与状态时间线可显示。
- 后端同时判断权限码、数据归属与当前状态。
- 编辑优先级会重新计算 SLA 时间。
面试时这一章能怎么讲
最难的部分是角色权限与状态流转组合校验。我使用动作驱动的后端 Service 集中判断合法转移,同时记录每次流转日志;待受理编辑重用创建表单,并在优先级变化时根据原创建时间重算 SLA 截止时间。