TTeamDesk 学习手册

src/shared/ticket.ts

此代码来自构建时的仓库快照。修改源码后重新构建即可更新。

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

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。