src/shared/ticket.ts
此代码来自构建时的仓库快照。修改源码后重新构建即可更新。
import { z } from 'zod';
export const ticketStatuses = [
'OPEN',
'IN_PROGRESS',
'RESOLVED',
'CLOSED',
] as const;
export const ticketPriorities = ['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const;
export type TicketStatus = (typeof ticketStatuses)[number];
export type TicketPriority = (typeof ticketPriorities)[number];
export const statusLabels: Record<TicketStatus, string> = {
OPEN: '待处理',
IN_PROGRESS: '处理中',
RESOLVED: '已解决',
CLOSED: '已关闭',
};
export const priorityLabels: Record<TicketPriority, string> = {
LOW: '低',
NORMAL: '普通',
HIGH: '高',
URGENT: '紧急',
};
// 规则放在共享目录,前端用于字段提示,服务端再次执行,不能只信任浏览器校验。
export const createTicketSchema = z.object({
title: z.string().trim().min(1, '请输入工单标题').max(100, '标题最多 100 字'),
description: z
.string()
.trim()
.min(1, '请描述遇到的问题')
.max(10000, '问题描述最多 10000 字'),
priority: z.enum(ticketPriorities),
});
export type CreateTicketInput = z.infer<typeof createTicketSchema>;
export const ticketIdSchema = z.object({ id: z.uuid('工单编号无效') });
export const listTicketsSchema = z.object({
page: z.number().int().min(1).max(100000).default(1),
pageSize: z.number().int().min(1).max(50).default(10),
status: z.enum(ticketStatuses).optional(),
q: z.string().trim().max(100, '搜索词最多100字').default(''),
priority: z.enum(ticketPriorities).optional(),
assignment: z.enum(['all', 'mine', 'unassigned']).default('all'),
});
export const updateTicketStatusSchema = ticketIdSchema.extend({
status: z.enum(ticketStatuses),
reason: z.string().trim().max(1000, '操作说明最多1000字').default(''),
version: z.number().int().min(0),
});
// 状态是业务规则,不能让客户端任意填写;已解决可重新打开,已关闭为最终状态。
export const allowedTransitions: Record<TicketStatus, readonly TicketStatus[]> =
{
OPEN: ['IN_PROGRESS', 'CLOSED'],
IN_PROGRESS: ['OPEN', 'RESOLVED', 'CLOSED'],
RESOLVED: ['OPEN', 'CLOSED'],
CLOSED: [],
};
export const assignTicketSchema = ticketIdSchema.extend({
assigneeId: z.uuid().nullable(),
version: z.number().int().min(0),
});
export const replyContentSchema = z
.string()
.trim()
.min(1, '请输入回复内容')
.max(10000, '回复最多10000字');
export const createReplySchema = ticketIdSchema.extend({
content: replyContentSchema,
clientRequestId: z.uuid(),
});
export const listRepliesSchema = ticketIdSchema.extend({
page: z.number().int().min(1).max(100000).default(1),
pageSize: z.number().int().min(1).max(50).default(20),
});
export const listTicketEventsSchema = ticketIdSchema.extend({
page: z.number().int().min(1).max(100000).default(1),
pageSize: z.number().int().min(1).max(50).default(10),
});
export function statusNeedsReason(from: TicketStatus, to: TicketStatus) {
return (
to === 'RESOLVED' ||
to === 'CLOSED' ||
(from === 'RESOLVED' && to === 'OPEN')
);
}
// UI 提示与服务端共用业务规则,但服务端必须独立执行,不能信任按钮是否被禁用。
export function statusChangeIssue(
from: TicketStatus,
to: TicketStatus,
reason: string,
) {
if (from === to) return '工单已经处于该状态,无需重复修改';
if (!allowedTransitions[from].includes(to))
return '不允许此状态变更,请查看可用操作';
if (statusNeedsReason(from, to) && !reason.trim())
return '解决、关闭或重新打开工单时,请填写操作说明';
return null;
}