09 工单表单与列表:创建请求并能准确找回来
09 工单表单与列表:创建请求并能准确找回来
本章你会做出什么
用户可以填写表单创建工单;“我的工单”和“工单中心”会显示分页列表,并支持关键词、状态、优先级、处理人筛选。筛选内容和页码会写入地址栏,从详情返回后仍可恢复。
先理解三个概念
1. 表单校验
标题不能为空、描述不能太短、分类必须存在。前端校验让用户立即看到提示;后端仍要校验,因为任何人都能直接构造请求。
2. 分页与筛选
列表数据不应一次取全部。页面发送 page=1&pageSize=10&status=PENDING,后端只返回当前页及总数。
3. URL query 状态
查询参数是问号后面的内容:
/tickets/my?keyword=网络&status=PENDING&page=2
把筛选保存到 URL 后,返回上一页、刷新或复制链接时查询上下文仍然存在。
本章最终目录变化
client/src/
api/modules.ts # 增加工单 API
components/
PriorityTag.vue
SlaTag.vue
StatusTag.vue
TicketFilter.vue
TicketForm.vue
utils/business.ts
utils/ticket-query.ts
views/tickets/
TicketCreateView.vue
TicketListView.vue
server/src/
store/store.ts # 增加工单存储方法
store/memory-store.ts # 增加工单数组与查询
app.ts # 增加分类、列表、新建接口
一步一步操作
第 1 步:后端增加工单存储能力
第 3 章的完整后端类型文件已经包含 TicketQuery;请确认它仍保留以下定义:
export interface TicketQuery {
page: number
pageSize: number
keyword?: string
status?: TicketStatus
priority?: TicketPriority
assigneeId?: number
scope: 'all' | 'created' | 'assigned' | 'none'
scopeUserId?: number
}
在 TicketStore 接口加入:
listTickets(query: {
page: number
pageSize: number
keyword?: string
status?: TicketStatus
priority?: TicketPriority
assigneeId?: number
scope: 'all' | 'created' | 'assigned' | 'none'
scopeUserId?: number
}): Promise<Pagination<Ticket>>
createTicket(input: Omit<Ticket, 'id' | 'ticketNo'>): Promise<Ticket>
内存 Store 增加 private tickets: Ticket[] = [],新建工单时生成编号、创建时间和 SLA 截止时间:
const SLA_HOURS = { HIGH: 4, MEDIUM: 24, LOW: 72 } as const
const dueAtForPriority = (priority: TicketPriority, createdAt: Date) =>
new Date(createdAt.getTime() + SLA_HOURS[priority] * 60 * 60 * 1000).toISOString()
async createTicket(input: Omit<Ticket, 'id' | 'ticketNo'>) {
const id = Math.max(0, ...this.tickets.map((ticket) => ticket.id)) + 1
const ticket = {
...input,
id,
ticketNo: `WO${input.createdAt.slice(0, 10).replaceAll('-', '')}${String(id).padStart(4, '0')}`,
}
this.tickets.push(ticket)
return ticket
}
列表方法务必先判断访问范围,再判断筛选条件:
const visible =
query.scope === 'all' ||
(query.scope === 'created' && ticket.creatorId === query.scopeUserId) ||
(query.scope === 'assigned' && ticket.assigneeId === query.scopeUserId)
如果当前用户没有 ticket:view_all、ticket:view_assigned 或 ticket:view_own,使用 scope: 'none',返回空列表。
第 2 步:在 API 路由中确定读取范围
在 server/src/app.ts 增加路由时,不要因为用户能“创建”就默认能“查看”:
const ticketScope = (user: AuthUser, mine: boolean) => {
if (mine && user.permissionCodes.includes('ticket:view_own')) return 'created'
if (user.permissionCodes.includes('ticket:view_all')) return 'all'
if (user.permissionCodes.includes('ticket:view_assigned')) return 'assigned'
if (user.permissionCodes.includes('ticket:view_own')) return 'created'
return 'none'
}
app.get('/api/v1/tickets', authenticated, async (request, response) => {
const page = Math.max(1, Number(request.query.page ?? 1))
const pageSize = Math.min(100, Math.max(1, Number(request.query.pageSize ?? 10)))
const mine = request.query.mine === 'true'
success(
response,
await store.listTickets({
page,
pageSize,
keyword: typeof request.query.keyword === 'string' ? request.query.keyword : undefined,
status: request.query.status as TicketStatus | undefined,
priority: request.query.priority as TicketPriority | undefined,
assigneeId: request.query.assigneeId ? Number(request.query.assigneeId) : undefined,
scope: ticketScope(request.currentUser!, mine),
scopeUserId: request.currentUser!.id
})
)
})
app.post('/api/v1/tickets', authenticated, requirePermission('ticket:create'), async (request, response) => {
const input = z
.object({
title: z.string().trim().min(3).max(100),
categoryId: z.number().int().positive(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),
description: z.string().trim().min(5).max(2000)
})
.parse(request.body)
const now = new Date()
const createdAt = now.toISOString()
const ticket = await store.createTicket({
...input,
status: 'PENDING',
creatorId: request.currentUser!.id,
assigneeId: null,
dueAt: dueAtForPriority(input.priority, now),
acceptedAt: null,
resolvedAt: null,
closedAt: null,
createdAt,
updatedAt: createdAt
})
success(response, ticket, '创建成功')
})
这里的 z、TicketStatus、TicketPriority 和 dueAtForPriority 按前章相应导入。
第 3 步:前端增加 API
将以下内容合并到 client/src/api/modules.ts:
import type { PageResult, Ticket, TicketFormValue, TicketPriority, TicketStatus } from '../types'
export const ticketApi = {
list: (params: {
page: number
pageSize: number
keyword?: string
status?: TicketStatus
priority?: TicketPriority
assigneeId?: number
mine?: boolean
}) => api.get<PageResult<Ticket>>('/tickets', { params }),
create: (input: TicketFormValue) => api.post<Ticket>('/tickets', input)
}
第 4 步:创建复用表单和标签组件
表单只关心字段和校验;新建页负责提交请求。下一章详情编辑也能重用同一个表单。
关键文件完整代码
client/src/components/StatusTag.vue
<script setup lang="ts">
import type { TicketStatus } from '../types'
import { statusLabel, statusTag } from '../utils/business'
defineProps<{ status: TicketStatus }>()
</script>
<template>
<el-tag :type="statusTag(status)" effect="light">{{ statusLabel(status) }}</el-tag>
</template>
client/src/utils/business.ts
import type { Ticket, TicketPriority, TicketStatus } from '../types'
export const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: 'PENDING', label: '待受理' },
{ value: 'PROCESSING', label: '处理中' },
{ value: 'WAITING_CONFIRM', label: '待确认' },
{ value: 'CLOSED', label: '已关闭' },
{ value: 'CANCELLED', label: '已取消' }
]
export const priorityOptions: Array<{ value: TicketPriority; label: string }> = [
{ value: 'HIGH', label: '高' },
{ value: 'MEDIUM', label: '中' },
{ value: 'LOW', label: '低' }
]
export const statusLabel = (value: TicketStatus) => statusOptions.find((option) => option.value === value)?.label ?? value
export const priorityLabel = (value: TicketPriority) => priorityOptions.find((option) => option.value === value)?.label ?? value
export const statusTag = (value: TicketStatus) =>
({ PENDING: 'warning', PROCESSING: 'primary', WAITING_CONFIRM: 'info', CLOSED: 'success', CANCELLED: 'danger' })[value] as
| 'warning'
| 'primary'
| 'info'
| 'success'
| 'danger'
export const priorityTag = (value: TicketPriority) =>
({ HIGH: 'danger', MEDIUM: 'warning', LOW: 'info' })[value] as 'danger' | 'warning' | 'info'
export const formatDate = (value?: string | null) =>
value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : '-'
export const slaStatus = (ticket: Pick<Ticket, 'status' | 'dueAt'>) => {
if (ticket.status === 'CLOSED' || ticket.status === 'CANCELLED') return { label: '已结束', type: 'info' as const }
const remaining = new Date(ticket.dueAt).getTime() - Date.now()
if (remaining < 0) return { label: '已超时', type: 'danger' as const }
if (remaining <= 2 * 60 * 60 * 1000) return { label: '即将超时', type: 'warning' as const }
return { label: '正常', type: 'success' as const }
}
client/src/components/PriorityTag.vue
<script setup lang="ts">
import type { TicketPriority } from '../types'
import { priorityLabel, priorityTag } from '../utils/business'
defineProps<{ priority: TicketPriority }>()
</script>
<template>
<el-tag :type="priorityTag(priority)" effect="plain">{{ priorityLabel(priority) }}</el-tag>
</template>
client/src/components/SlaTag.vue
本章先将 SLA 作为列表提示标签落地;第 12 章会把同一规则汇总到运营看板。
<script setup lang="ts">
import { computed } from 'vue'
import type { Ticket } from '../types'
import { slaStatus } from '../utils/business'
const props = defineProps<{ ticket: Pick<Ticket, 'status' | 'dueAt'> }>()
const state = computed(() => slaStatus(props.ticket))
</script>
<template>
<el-tag :type="state.type" effect="plain" size="small">{{ state.label }}</el-tag>
</template>
client/src/components/TicketFilter.vue
<script setup lang="ts">
import type { TicketPriority, TicketStatus } from '../types'
import { priorityOptions, statusOptions } from '../utils/business'
interface Filters {
keyword: string
status?: TicketStatus
priority?: TicketPriority
assigneeId?: number
}
const props = defineProps<{ modelValue: Filters; assignees?: Array<{ id: number; name: string }> }>()
const emit = defineEmits<{ 'update:modelValue': [value: Filters]; search: []; reset: [] }>()
const update = (input: Partial<Filters>) => emit('update:modelValue', { ...props.modelValue, ...input })
</script>
<template>
<el-form inline class="filters">
<el-form-item label="关键词">
<el-input
:model-value="modelValue.keyword"
clearable
placeholder="标题或工单编号"
style="width: 210px"
@update:model-value="(value: string) => update({ keyword: value })"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
:model-value="modelValue.status"
clearable
placeholder="全部状态"
style="width: 135px"
@update:model-value="(value?: TicketStatus) => update({ status: value })"
>
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select
:model-value="modelValue.priority"
clearable
placeholder="全部"
style="width: 110px"
@update:model-value="(value?: TicketPriority) => update({ priority: value })"
>
<el-option v-for="option in priorityOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item v-if="assignees?.length" label="负责人">
<el-select
:model-value="modelValue.assigneeId"
clearable
placeholder="全部"
style="width: 130px"
@update:model-value="(value?: number) => update({ assigneeId: value })"
>
<el-option v-for="person in assignees" :key="person.id" :label="person.name" :value="person.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="emit('search')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</el-form-item>
</el-form>
</template>
<style scoped>
.filters {
margin-bottom: -18px;
}
</style>
client/src/stores/dictionary.ts
分类会在新建、编辑等多个表单复用。创建字典 Store,只缓存跨页面复用且变化不频繁的分类数据:
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { commonApi } from '../api/modules'
import type { Category } from '../types'
export const useDictionaryStore = defineStore('dictionary', () => {
const categories = ref<Category[]>([])
async function loadCategories(force = false) {
if (!categories.value.length || force) categories.value = await commonApi.categories()
return categories.value
}
return { categories, loadCategories }
})
client/src/components/TicketForm.vue
此文件既供新建页使用,也会在下一章用于待受理工单编辑。请替换为完整文件:
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { reactive, ref, watch } from 'vue'
import { useDictionaryStore } from '../stores/dictionary'
import type { TicketFormValue, TicketPriority } from '../types'
import { priorityOptions } from '../utils/business'
const props = withDefaults(defineProps<{ submitting?: boolean; initialValue?: TicketFormValue; submitLabel?: string }>(), {
submitting: false,
submitLabel: '提交工单'
})
const emit = defineEmits<{
submit: [value: TicketFormValue]
cancel: []
}>()
const dictionary = useDictionaryStore()
const formRef = ref<FormInstance>()
const form = reactive({
title: '',
categoryId: undefined as number | undefined,
priority: 'MEDIUM' as TicketPriority,
description: ''
})
const rules: FormRules = {
title: [
{ required: true, message: '请输入工单标题', trigger: 'blur' },
{ min: 3, max: 100, message: '标题长度为 3-100 个字符', trigger: 'blur' }
],
categoryId: [{ required: true, message: '请选择问题分类', trigger: 'change' }],
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
description: [
{ required: true, message: '请描述具体问题', trigger: 'blur' },
{ min: 5, max: 2000, message: '描述长度为 5-2000 个字符', trigger: 'blur' }
]
}
dictionary.loadCategories()
watch(
() => props.initialValue,
(value) => {
if (value) Object.assign(form, value)
},
{ immediate: true }
)
const submit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (valid && form.categoryId) {
emit('submit', {
title: form.title.trim(),
categoryId: form.categoryId,
priority: form.priority,
description: form.description.trim()
})
}
}
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ticket-form">
<el-form-item label="工单标题" prop="title">
<el-input v-model="form.title" placeholder="简要描述需要处理的问题" maxlength="100" show-word-limit />
</el-form-item>
<div class="row">
<el-form-item label="问题分类" prop="categoryId">
<el-select v-model="form.categoryId" placeholder="选择分类">
<el-option v-for="category in dictionary.categories" :key="category.id" :label="category.name" :value="category.id" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-select v-model="form.priority">
<el-option v-for="option in priorityOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
</div>
<el-form-item label="问题描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="8"
maxlength="2000"
show-word-limit
placeholder="说明问题现象、发生时间以及已尝试的处理方式"
/>
</el-form-item>
<el-alert type="info" show-icon :closable="false">
高优先级工单将在 4 小时内进入 SLA 超时预警,请准确选择紧急程度。
</el-alert>
<div class="actions">
<el-button @click="emit('cancel')">取消</el-button>
<el-button type="primary" :loading="props.submitting" @click="submit">{{ props.submitLabel }}</el-button>
</div>
</el-form>
</template>
<style scoped>
.ticket-form {
max-width: 760px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px;
}
.row .el-select {
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 26px;
}
</style>
client/src/utils/ticket-query.ts
import type { TicketPriority, TicketStatus } from '../types'
type QueryValue = string | null | Array<string | null> | undefined
export interface TicketListState {
page: number
pageSize: number
keyword: string
status?: TicketStatus
priority?: TicketPriority
assigneeId?: number
}
const ticketStatuses = new Set<TicketStatus>(['PENDING', 'PROCESSING', 'WAITING_CONFIRM', 'CLOSED', 'CANCELLED'])
const ticketPriorities = new Set<TicketPriority>(['LOW', 'MEDIUM', 'HIGH'])
const firstValue = (value: QueryValue) => (Array.isArray(value) ? value.find((item) => item !== null) : value) ?? undefined
const positiveInteger = (value: QueryValue, fallback: number) => {
const number = Number(firstValue(value))
return Number.isInteger(number) && number > 0 ? number : fallback
}
export const readTicketListQuery = (query: Record<string, QueryValue>): TicketListState => {
const status = firstValue(query.status) as TicketStatus | undefined
const priority = firstValue(query.priority) as TicketPriority | undefined
const assigneeId = positiveInteger(query.assigneeId, 0)
return {
page: positiveInteger(query.page, 1),
pageSize: Math.min(100, positiveInteger(query.pageSize, 10)),
keyword: firstValue(query.keyword) ?? '',
status: status && ticketStatuses.has(status) ? status : undefined,
priority: priority && ticketPriorities.has(priority) ? priority : undefined,
assigneeId: assigneeId || undefined
}
}
export const writeTicketListQuery = (state: TicketListState) => {
const query: Record<string, string> = {}
if (state.page !== 1) query.page = String(state.page)
if (state.pageSize !== 10) query.pageSize = String(state.pageSize)
if (state.keyword.trim()) query.keyword = state.keyword.trim()
if (state.status) query.status = state.status
if (state.priority) query.priority = state.priority
if (state.assigneeId) query.assigneeId = String(state.assigneeId)
return query
}
client/src/views/tickets/TicketCreateView.vue
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ticketApi } from '../../api/modules'
import TicketForm from '../../components/TicketForm.vue'
import type { TicketFormValue } from '../../types'
const submitting = ref(false)
const router = useRouter()
const createTicket = async (input: TicketFormValue) => {
submitting.value = true
try {
const ticket = await ticketApi.create(input)
ElMessage.success('工单提交成功')
await router.push(`/tickets/${ticket.id}`)
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">新建工单</h1>
<p class="page-subtitle">提交需要服务团队处理的问题</p>
</div>
</div>
<el-card shadow="never" class="content-card form-card">
<TicketForm :submitting="submitting" @submit="createTicket" @cancel="router.back()" />
</el-card>
</div>
</template>
<style scoped>
.form-card {
padding: 10px 12px 14px;
}
</style>
client/src/views/tickets/TicketListView.vue
<script setup lang="ts">
defineOptions({ name: 'TicketListView' })
import { computed, ref, watch } 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 TicketFilter from '../../components/TicketFilter.vue'
import { useAuthStore } from '../../stores/auth'
import type { PageResult, Ticket, TicketPriority, TicketStatus } from '../../types'
import { formatDate } from '../../utils/business'
import { readTicketListQuery, writeTicketListQuery } from '../../utils/ticket-query'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const result = ref<PageResult<Ticket>>({ list: [], page: 1, pageSize: 10, total: 0 })
const assignees = ref<Array<{ id: number; name: string }>>([])
const filters = ref<{ keyword: string; status?: TicketStatus; priority?: TicketPriority; assigneeId?: number }>({
keyword: ''
})
const isMine = () => route.name === 'my-tickets'
const location = computed(() => route.fullPath)
const load = async () => {
loading.value = true
try {
result.value = await ticketApi.list({
page: result.value.page,
pageSize: result.value.pageSize,
...filters.value,
mine: isMine()
})
} finally {
loading.value = false
}
}
const updateQuery = () =>
router.replace({
query: writeTicketListQuery({ ...filters.value, page: result.value.page, pageSize: result.value.pageSize })
})
const search = () => {
result.value.page = 1
updateQuery()
}
const reset = () => {
filters.value = { keyword: '' }
result.value.page = 1
updateQuery()
}
const changePage = (page: number) => {
result.value.page = page
updateQuery()
}
const changePageSize = (pageSize: number) => {
result.value.page = 1
result.value.pageSize = pageSize
updateQuery()
}
const restoreQuery = () => {
const state = readTicketListQuery(route.query)
filters.value = {
keyword: state.keyword,
status: state.status,
priority: state.priority,
assigneeId: state.assigneeId
}
result.value.page = state.page
result.value.pageSize = state.pageSize
}
const openTicket = (ticket: Ticket) => router.push({ path: `/tickets/${ticket.id}`, query: { from: route.fullPath } })
watch(
location,
async () => {
restoreQuery()
if (!isMine() && auth.hasPermission('ticket:assign')) assignees.value = await commonApi.assignees()
await load()
},
{ immediate: true }
)
</script>
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">{{ isMine() ? '我的工单' : '工单中心' }}</h1>
<p class="page-subtitle">{{ isMine() ? '查看本人提交的服务请求与处理状态' : '查询、分配并跟踪团队待处理工单' }}</p>
</div>
<el-button v-permission="'ticket:create'" type="primary" @click="router.push('/tickets/create')">新建工单</el-button>
</div>
<el-card shadow="never" class="filter-card">
<TicketFilter v-model="filters" :assignees="isMine() ? [] : assignees" @search="search" @reset="reset" />
</el-card>
<el-card shadow="never" class="content-card">
<el-table v-loading="loading" :data="result.list" @row-click="openTicket">
<el-table-column prop="ticketNo" label="工单编号" width="155" />
<el-table-column prop="title" label="工单标题" min-width="220" />
<el-table-column prop="categoryName" label="分类" width="112" />
<el-table-column label="优先级" width="88">
<template #default="{ row }"><PriorityTag :priority="row.priority" /></template>
</el-table-column>
<el-table-column label="状态" width="105">
<template #default="{ row }"><StatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column label="SLA" width="105">
<template #default="{ row }"><SlaTag :ticket="row" /></template>
</el-table-column>
<el-table-column prop="assigneeName" label="负责人" width="105">
<template #default="{ row }">{{ row.assigneeName || '-' }}</template>
</el-table-column>
<el-table-column label="提交时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="result.page"
:page-size="result.pageSize"
:total="result.total"
layout="total, sizes, prev, pager, next"
@current-change="changePage"
@size-change="changePageSize"
/>
</el-card>
</div>
</template>
启动并验证
- 用
user登录,进入新建工单页,填写标题、分类、优先级和描述。 - 提交后检查工单在列表中出现,初始状态为“待受理”。
- 在列表选择状态和关键词,确认 URL 出现 query。
- 刷新列表,确认筛选仍保留。
常见报错与原因
| 现象 | 原因 | 修正 |
|---|---|---|
| 分类下拉为空 | 未携带 Token 或分类接口未加入 app.ts |
查看 Network 的 /categories 响应 |
| 创建后列表看不到工单 | 读取范围未按 creatorId 实现 |
检查 scope === 'created' 过滤 |
| 返回列表时筛选丢失 | 未将 query 传给详情或返回按钮写死路径 | 使用 from: route.fullPath |
本章完成清单
- 用户可以创建一张待受理工单。
- 列表支持分页和组合筛选。
- 数据读取范围依据权限码决定。
- URL 中能保存当前筛选和页码。
面试时这一章能怎么讲
工单表单封装为可供新建与编辑复用的组件;列表查询参数同步到路由 query,使从详情返回时可恢复筛选分页上下文。后端严格按照读取权限码计算数据范围,而不是从创建权限推断可查看范围。