03 TypeScript 与业务类型:让状态不能被随意写错
03 TypeScript 与业务类型:让状态不能被随意写错
本章你会做出什么
你会为前端和后端各创建一份业务类型文件,规定角色、优先级、工单状态、表单和接口返回值的形状。页面还没有完成,但项目已经拥有共同的“数据语言”。
先理解三个概念
1. 类型是代码中的规则
如果工单状态只是普通字符串,你可能写出 PROCCESSING 或 DONE,运行到页面才发现错误。类型可以提前限制可选值:
type TicketStatus = 'PENDING' | 'PROCESSING' | 'WAITING_CONFIRM' | 'CLOSED' | 'CANCELLED'
2. type 与 interface
type适合表示几个确定的可选值,例如角色代码。interface适合描述一个对象具有哪些字段,例如一张工单。
3. 接口契约
后端返回一张 Ticket,前端也按 Ticket 展示。双方字段名字一致,就叫契约一致。个人项目中先分别维护同名类型,理解其作用;实际团队还可能从 API 文档自动生成前端类型。
接口最外层也有统一契约。例如所有成功响应都有 code、message、data 和 requestId,其中真正业务数据的类型由泛型 T 决定:
interface ApiResponse<T> {
code: number
message: string
data: T
requestId: string
}
本章最终目录变化
client/src/
types/
index.ts
server/src/
constants.ts
types.ts
一步一步操作
第 1 步:创建前端类型目录
在根目录打开 PowerShell:
mkdir client\src\types
新建 client/src/types/index.ts,复制下一节的完整前端类型代码。
第 2 步:创建后端类型文件
新建 server/src/types.ts。后端类型比前端多 UserRecord,原因是后端需要保存密码哈希,而前端永远不应收到密码字段。
第 3 步:建立固定权限和 SLA 规则
新建 server/src/constants.ts。这里保存默认权限码、四个默认角色、中文状态标签和优先级处理时限。
关键文件完整代码
client/src/types/index.ts
export type RoleCode = 'USER' | 'AGENT' | 'SUPERVISOR' | 'ADMIN'
export type TicketStatus = 'PENDING' | 'PROCESSING' | 'WAITING_CONFIRM' | 'CLOSED' | 'CANCELLED'
export type TicketPriority = 'LOW' | 'MEDIUM' | 'HIGH'
export type TicketAction = 'SUBMIT_RESOLUTION' | 'CONFIRM_CLOSE' | 'REOPEN' | 'CANCEL'
export interface ApiResponse<T> {
code: number
message: string
data: T
requestId: string
}
export interface AuthUser {
id: number
username: string
name: string
email: string
roleId: number
roleCode: RoleCode
roleName: string
permissionCodes: string[]
}
export interface Category {
id: number
name: string
enabled: boolean
createdAt: string
}
export interface Ticket {
id: number
ticketNo: string
title: string
description: string
categoryId: number
categoryName: string
priority: TicketPriority
status: TicketStatus
creatorId: number
creatorName: string
assigneeId: number | null
assigneeName: string | null
dueAt: string
acceptedAt: string | null
resolvedAt: string | null
closedAt: string | null
createdAt: string
updatedAt: string
}
export interface TicketComment {
id: number
userName: string
content: string
createdAt: string
}
export interface TicketLog {
id: number
fromStatus: TicketStatus | null
toStatus: TicketStatus
operatorName: string
remark: string
createdAt: string
}
export interface TicketDetail extends Ticket {
comments: TicketComment[]
logs: TicketLog[]
}
export type TicketFormValue = Pick<Ticket, 'title' | 'categoryId' | 'priority' | 'description'>
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
export interface DashboardData {
cards: {
total: number
pending: number
processing: number
waitingConfirm: number
closed: number
overdue: number
}
statusDistribution: Array<{ name: string; value: number }>
categoryRanking: Array<{ name: string; value: number }>
dailyTrend: Array<{ date: string; created: number; closed: number }>
recentTickets: Ticket[]
}
export interface Role {
id: number
code: RoleCode
name: string
description: string
permissionCodes: string[]
}
export interface Permission {
id: number
code: string
name: string
type: 'MENU' | 'BUTTON'
}
export interface ManagedUser {
id: number
username: string
name: string
email: string
roleId: number
roleCode: RoleCode
roleName: string
status: 0 | 1
}
server/src/types.ts
export type RoleCode = 'USER' | 'AGENT' | 'SUPERVISOR' | 'ADMIN'
export type PermissionType = 'MENU' | 'BUTTON'
export type TicketStatus = 'PENDING' | 'PROCESSING' | 'WAITING_CONFIRM' | 'CLOSED' | 'CANCELLED'
export type TicketPriority = 'LOW' | 'MEDIUM' | 'HIGH'
export type TicketAction = 'SUBMIT_RESOLUTION' | 'CONFIRM_CLOSE' | 'REOPEN' | 'CANCEL'
export interface ApiResponse<T> {
code: number
message: string
data: T
requestId: string
}
export interface Permission {
id: number
code: string
name: string
type: PermissionType
routePath?: string
}
export interface Role {
id: number
code: RoleCode
name: string
description: string
permissionCodes: string[]
}
export interface UserRecord {
id: number
username: string
passwordHash: string
name: string
email: string
roleId: number
status: 0 | 1
createdAt: string
updatedAt: string
}
export interface AuthUser {
id: number
username: string
name: string
email: string
status: 0 | 1
roleId: number
roleCode: RoleCode
roleName: string
permissionCodes: string[]
}
export interface Category {
id: number
name: string
enabled: boolean
createdAt: string
}
export interface Ticket {
id: number
ticketNo: string
title: string
description: string
categoryId: number
categoryName?: string
priority: TicketPriority
status: TicketStatus
creatorId: number
creatorName?: string
assigneeId: number | null
assigneeName?: string | null
dueAt: string
acceptedAt: string | null
resolvedAt: string | null
closedAt: string | null
createdAt: string
updatedAt: string
}
export interface TicketComment {
id: number
ticketId: number
userId: number
userName?: string
content: string
createdAt: string
}
export interface TicketLog {
id: number
ticketId: number
fromStatus: TicketStatus | null
toStatus: TicketStatus
operatorId: number
operatorName?: string
remark: string
createdAt: string
}
export interface TicketDetail extends Ticket {
comments: TicketComment[]
logs: TicketLog[]
}
export type TicketFormValue = Pick<Ticket, 'title' | 'categoryId' | 'priority' | 'description'>
export interface Pagination<T> {
list: T[]
page: number
pageSize: number
total: number
}
export interface TicketQuery {
page: number
pageSize: number
keyword?: string
status?: TicketStatus
priority?: TicketPriority
assigneeId?: number
scope: 'all' | 'created' | 'assigned' | 'none'
scopeUserId?: number
}
export interface UserQuery {
page: number
pageSize: number
keyword?: string
roleCode?: RoleCode
status?: 0 | 1
}
export interface DashboardData {
cards: {
total: number
pending: number
processing: number
waitingConfirm: number
closed: number
overdue: number
}
statusDistribution: Array<{ name: string; value: number }>
categoryRanking: Array<{ name: string; value: number }>
dailyTrend: Array<{ date: string; created: number; closed: number }>
recentTickets: Ticket[]
}
server/src/constants.ts
import type { Permission, Role, TicketPriority, TicketStatus } from './types.js'
export const PERMISSIONS: Permission[] = [
{ id: 1, code: 'dashboard:view', name: '查看运营看板', type: 'MENU', routePath: '/dashboard' },
{ id: 2, code: 'dashboard:view_own', name: '查看个人看板', type: 'MENU', routePath: '/dashboard' },
{ id: 3, code: 'ticket:create', name: '创建工单', type: 'BUTTON' },
{ id: 4, code: 'ticket:view_own', name: '查看本人工单', type: 'MENU', routePath: '/tickets/my' },
{ id: 5, code: 'ticket:view_assigned', name: '查看分配工单', type: 'MENU', routePath: '/tickets/manage' },
{ id: 6, code: 'ticket:view_all', name: '查看全部工单', type: 'MENU', routePath: '/tickets/manage' },
{ id: 7, code: 'ticket:assign', name: '分配处理人', type: 'BUTTON' },
{ id: 8, code: 'ticket:handle', name: '处理工单', type: 'BUTTON' },
{ id: 9, code: 'ticket:confirm', name: '确认关闭工单', type: 'BUTTON' },
{ id: 10, code: 'ticket:cancel', name: '取消工单', type: 'BUTTON' },
{ id: 11, code: 'ticket:comment', name: '评论工单', type: 'BUTTON' },
{ id: 12, code: 'system:user', name: '用户管理', type: 'MENU', routePath: '/system/users' },
{ id: 13, code: 'system:role', name: '角色权限', type: 'MENU', routePath: '/system/roles' },
{ id: 14, code: 'system:category', name: '分类管理', type: 'MENU', routePath: '/system/categories' }
]
export const DEFAULT_ROLES: Role[] = [
{
id: 1,
code: 'USER',
name: '提单用户',
description: '创建并跟踪本人服务请求',
permissionCodes: ['ticket:create', 'ticket:view_own', 'ticket:confirm', 'ticket:cancel', 'ticket:comment']
},
{
id: 2,
code: 'AGENT',
name: '处理人员',
description: '处理指派给自己的工单',
permissionCodes: [
'dashboard:view_own',
'ticket:create',
'ticket:view_own',
'ticket:view_assigned',
'ticket:handle',
'ticket:confirm',
'ticket:cancel',
'ticket:comment'
]
},
{
id: 3,
code: 'SUPERVISOR',
name: '服务主管',
description: '分派工单并查看团队数据',
permissionCodes: [
'dashboard:view',
'ticket:create',
'ticket:view_own',
'ticket:view_all',
'ticket:assign',
'ticket:handle',
'ticket:confirm',
'ticket:cancel',
'ticket:comment'
]
},
{
id: 4,
code: 'ADMIN',
name: '系统管理员',
description: '维护系统配置',
permissionCodes: PERMISSIONS.map((permission) => permission.code)
}
]
export const STATUS_LABELS: Record<TicketStatus, string> = {
PENDING: '待受理',
PROCESSING: '处理中',
WAITING_CONFIRM: '待确认',
CLOSED: '已关闭',
CANCELLED: '已取消'
}
export const SLA_HOURS: Record<TicketPriority, number> = { HIGH: 4, MEDIUM: 24, LOW: 72 }
启动并验证
在 client/src/types/index.ts 最下方临时写:
const testStatus: TicketStatus = 'DONE'
console.log(testStatus)
在根目录运行:
npm run typecheck
你应看到 TypeScript 提示 DONE 不能赋给 TicketStatus。验证完毕后删除这两行,再运行一次命令,应通过。
常见报错与原因
| 报错 | 原因 | 修正 |
|---|---|---|
Cannot find module './types.js' |
NodeNext 后端导入必须写输出后的 .js 后缀 |
按示例使用 ./types.js |
| 前端字段提示不存在 | 前后端类型字段不一致 | 对照 Ticket 类型统一字段名字 |
DONE 为什么不能使用 |
我们没有将它设计为合法业务状态 | 使用 CLOSED 或扩展需求后再改类型 |
本章完成清单
- 前后端都有业务类型文件。
- 我知道为什么密码哈希只属于后端类型。
- 我知道页面中文标签与接口英文状态可以分开。
- 我亲自验证过 TypeScript 能拦住错误状态。
面试时这一章能怎么讲
我使用联合类型限制角色、状态、优先级和状态动作,使列表、详情与接口参数共享明确契约;展示文字通过状态映射处理,接口中保持稳定的英文枚举值。