src/features/tickets/workspace.tsx
此代码来自构建时的仓库快照。修改源码后重新构建即可更新。
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryStates } from 'nuqs';
import { ticketSearchParsers } from './url-state';
import { ReminderSettings } from '@/features/tasks/reminder';
import { TicketEvents } from './ticket-events';
import { Assignment, Conversation } from './collaboration';
import { useDraftStore } from './draft-provider';
import { draftKey } from '@/stores/drafts';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Alert,
App,
Button,
Card,
Descriptions,
Empty,
Form,
Input,
Modal,
Select,
Space,
Spin,
Table,
Tag,
} from 'antd';
import {
CustomerServiceOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useTRPC } from '@/trpc/client';
import { useTeam } from '@/features/teams/team-workspace';
import {
allowedTransitions,
statusNeedsReason,
statusChangeIssue,
createTicketSchema,
priorityLabels,
statusLabels,
ticketStatuses,
ticketPriorities,
type CreateTicketInput,
type TicketStatus,
} from '@/shared/ticket';
const statusColors = {
OPEN: 'blue',
IN_PROGRESS: 'processing',
RESOLVED: 'green',
CLOSED: 'default',
} as const;
const priorityColors = {
LOW: 'default',
NORMAL: 'blue',
HIGH: 'orange',
URGENT: 'red',
} as const;
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm');
function Frame({ children }: { children: React.ReactNode }) {
const team = useTeam();
return (
<main className="mx-auto min-h-screen max-w-6xl px-4 py-8 md:px-8">
<header className="mb-8 flex items-center justify-between">
<Link
href={`/?team=${team.id}`}
className="text-foreground flex items-center gap-3 text-xl font-semibold"
>
<CustomerServiceOutlined className="text-brand" />
TeamDesk
</Link>
<Tag color="blue">{team.name}</Tag>
</header>
<Alert
type="info"
showIcon
title="团队工单管理"
description={
team.role === 'CUSTOMER'
? '你只能查看自己提交的工单,状态由客服处理。'
: '当前仅展示本团队工单,你可以处理和更新工单状态。'
}
className="mb-6"
/>
{children}
</main>
);
}
function CreateTicket({ onCreated }: { onCreated: () => void }) {
const team = useTeam();
const trpc = useTRPC();
const { message } = App.useApp();
const router = useRouter();
const [open, setOpen] = useState(false);
const key = draftKey(team.id, 'new');
const draft = useDraftStore((state) => state.drafts[key]);
const hydrated = useDraftStore((state) => state.hydrated);
const save = useDraftStore((state) => state.save);
const remove = useDraftStore((state) => state.remove);
const [form] = Form.useForm<CreateTicketInput>();
const create = useMutation(
trpc.ticket.create.mutationOptions({
onSuccess(ticket) {
setOpen(false);
form.resetFields();
remove(key);
onCreated();
void message.success('工单已创建');
router.push(`/tickets/${ticket.id}?team=${team.id}`);
},
onError(error) {
const fields = error.data?.zodError?.fieldErrors as
Record<string, unknown> | undefined;
if (fields) {
form.setFields(
(['title', 'description', 'priority'] as const).map((name) => ({
name,
errors: Array.isArray(fields[name])
? fields[name].filter(
(value): value is string => typeof value === 'string',
)
: [],
})),
);
} else void message.error(error.message);
},
}),
);
const submit = (values: CreateTicketInput) => {
const result = createTicketSchema.safeParse(values);
if (!result.success) {
const fields = result.error.flatten().fieldErrors;
form.setFields(
(['title', 'description', 'priority'] as const).map((name) => ({
name,
errors: fields[name] ?? [],
})),
);
return;
}
create.mutate({ ...result.data, teamId: team.id });
};
return (
<>
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!hydrated}
onClick={() => {
form.setFieldsValue({
title: '',
description: '',
priority: 'NORMAL',
...draft?.ticket,
});
setOpen(true);
}}
>
创建工单
</Button>
<Modal
forceRender
title="创建工单"
open={open}
onCancel={() => {
if (!create.isPending) setOpen(false);
}}
footer={null}
mask={{ closable: !create.isPending }}
closable={!create.isPending}
>
<Form
form={form}
layout="vertical"
initialValues={{ priority: 'NORMAL' }}
onFinish={submit}
onValuesChange={(_, values) => save(key, { ticket: values })}
disabled={create.isPending}
>
<Form.Item
name="title"
label="工单标题"
rules={[
{ required: true, whitespace: true, message: '请输入工单标题' },
]}
>
<Input maxLength={100} showCount placeholder="用一句话描述问题" />
</Form.Item>
<Form.Item
name="description"
label="问题描述"
rules={[
{ required: true, whitespace: true, message: '请描述遇到的问题' },
]}
>
<Input.TextArea
rows={6}
maxLength={10000}
showCount
placeholder="发生了什么?预期结果是什么?如何重现?"
/>
</Form.Item>
<Form.Item
name="priority"
label="优先级"
rules={[{ required: true }]}
>
<Select
options={Object.entries(priorityLabels).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<p className="mb-4 text-xs text-slate-500">
未提交内容会保留为当前团队的本地草稿。
</p>
<div className="flex justify-end gap-2">
<Button
disabled={create.isPending}
onClick={() => {
remove(key);
form.setFieldsValue({
title: '',
description: '',
priority: 'NORMAL',
});
}}
>
清空草稿
</Button>
<Button onClick={() => setOpen(false)} disabled={create.isPending}>
取消
</Button>
<Button htmlType="submit" type="primary" loading={create.isPending}>
提交工单
</Button>
</div>
</Form>
</Modal>
</>
);
}
export function TicketList() {
const team = useTeam();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [filters, setFilters] = useQueryStates(ticketSearchParsers, {
history: 'push',
shallow: true,
});
const searchParams = useSearchParams();
const { page, pageSize, status, priority, assignment, q } = filters;
const router = useRouter();
const { message } = App.useApp();
const exportTask = useMutation(
trpc.task.createExport.mutationOptions({
onSuccess() {
void message.success('导出已受理');
router.push(`/tasks?team=${team.id}`);
},
onError(error) {
void message.error(error.message);
},
}),
);
const query = useQuery(
trpc.ticket.list.queryOptions({
teamId: team.id,
page,
pageSize,
status: status ?? undefined,
priority: priority ?? undefined,
assignment,
q,
}),
);
return (
<Frame>
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="mb-2 text-3xl font-semibold">工单工作台</h1>
<p className="text-slate-500">
记录问题、跟进处理,让每个请求都有结果。
</p>
</div>
<Space wrap>
<Button
loading={exportTask.isPending}
onClick={() =>
exportTask.mutate({
teamId: team.id,
filters: {
status: status ?? undefined,
priority: priority ?? undefined,
assignment,
q,
},
})
}
>
异步导出筛选结果
</Button>
<CreateTicket
onCreated={() => {
void queryClient.invalidateQueries({
queryKey: trpc.ticket.list.queryKey(),
});
}}
/>
</Space>
</div>
<Card>
<div className="mb-5 flex flex-wrap items-center justify-between gap-4">
<Space wrap>
<Input.Search
aria-label="搜索工单标题"
key={q}
defaultValue={q}
maxLength={100}
placeholder="搜索工单标题"
allowClear
className="w-56"
onSearch={(value) =>
void setFilters({ q: value.trim(), page: 1 })
}
/>
<span>状态</span>
<Select
aria-label="筛选工单状态"
allowClear
placeholder="全部状态"
className="w-36"
value={status ?? undefined}
options={ticketStatuses.map((value) => ({
value,
label: statusLabels[value],
}))}
onChange={(value) => {
void setFilters({ status: value ?? null, page: 1 });
}}
/>
<Select
aria-label="筛选优先级"
allowClear
placeholder="全部优先级"
className="w-32"
value={priority ?? undefined}
options={ticketPriorities.map((value) => ({
value,
label: priorityLabels[value],
}))}
onChange={(value) =>
void setFilters({ priority: value ?? null, page: 1 })
}
/>
{team.role !== 'CUSTOMER' && (
<Select
aria-label="筛选负责人"
className="w-36"
value={assignment}
options={[
{ value: 'all', label: '全部分配' },
{ value: 'mine', label: '分配给我' },
{ value: 'unassigned', label: '未分配' },
]}
onChange={(value) =>
void setFilters({ assignment: value, page: 1 })
}
/>
)}
<Button onClick={() => void setFilters(null)}>重置筛选</Button>
</Space>
<Button
icon={<ReloadOutlined />}
loading={query.isFetching}
onClick={() => void query.refetch()}
>
刷新
</Button>
</div>
{query.isError && (
<Alert
className="mb-4"
type="error"
showIcon
title="工单加载失败"
description={query.error.message}
action={<Button onClick={() => void query.refetch()}>重试</Button>}
/>
)}
<Table
rowKey="id"
loading={query.isLoading}
dataSource={query.data?.items ?? []}
scroll={{ x: 760 }}
locale={{
emptyText: (
<Empty
description={
query.isError
? '暂时无法读取工单'
: '暂无工单,创建第一条工单吧'
}
/>
),
}}
pagination={{
current: page,
pageSize,
pageSizeOptions: [10, 20, 50],
total: query.data?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条工单`,
onChange: (next, size) =>
void setFilters({
page: size === pageSize ? next : 1,
pageSize: size,
}),
}}
columns={[
{
title: '编号',
dataIndex: 'number',
width: 100,
render: (number: number) =>
`#${number.toString().padStart(4, '0')}`,
},
{
title: '标题',
dataIndex: 'title',
render: (_, ticket) => (
<Link
href={`/tickets/${ticket.id}?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), team: team.id }).toString()}`}
>
{ticket.title}
</Link>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (value: TicketStatus) => (
<Tag color={statusColors[value]}>{statusLabels[value]}</Tag>
),
},
{
title: '优先级',
dataIndex: 'priority',
width: 100,
render: (_, ticket) => (
<Tag color={priorityColors[ticket.priority]}>
{priorityLabels[ticket.priority]}
</Tag>
),
},
{
title: '负责人',
render: (_, ticket) => ticket.assignee?.user.name ?? '未分配',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 180,
render: formatTime,
},
]}
/>
</Card>
</Frame>
);
}
export function TicketDetail({ id }: { id: string }) {
const team = useTeam();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { message } = App.useApp();
const query = useQuery(
trpc.ticket.detail.queryOptions({ id, teamId: team.id }),
);
const [nextStatus, setNextStatus] = useState<TicketStatus>();
const [reason, setReason] = useState('');
const searchParams = useSearchParams();
const listHref = `/?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), team: team.id }).toString()}`;
const update = useMutation(
trpc.ticket.updateStatus.mutationOptions({
async onSuccess() {
setNextStatus(undefined);
setReason('');
await Promise.all([
queryClient.invalidateQueries({
queryKey: trpc.ticket.events.queryKey({ id, teamId: team.id }),
}),
queryClient.invalidateQueries({
queryKey: trpc.ticket.list.queryKey(),
}),
queryClient.invalidateQueries({
queryKey: trpc.ticket.detail.queryKey({ id, teamId: team.id }),
}),
]);
void message.success('工单状态已更新');
},
onError(error) {
void message.error(error.message);
if (error.data?.code === 'CONFLICT') {
setNextStatus(undefined);
void query.refetch();
}
},
}),
);
const ticket = query.data;
return (
<Frame>
<Link href={listHref} className="mb-5 inline-block">
← 返回工单列表
</Link>
{query.isLoading && (
<div className="py-20 text-center">
<Spin aria-label="正在加载工单" />
</div>
)}
{query.isError && (
<Alert
type="error"
showIcon
title={
query.error.data?.code === 'NOT_FOUND'
? '工单不存在'
: '工单加载失败'
}
description={query.error.message}
action={<Button onClick={() => void query.refetch()}>重试</Button>}
/>
)}
{ticket && (
<>
<div className="mb-6">
<p className="mb-2 text-slate-500">
工单 #{ticket.number.toString().padStart(4, '0')}
</p>
<h1 className="text-3xl font-semibold break-words">
{ticket.title}
</h1>
</div>
<div className="grid items-start gap-6 lg:grid-cols-[1fr_320px]">
<div className="space-y-6">
<Card title="问题描述">
<p className="min-h-40 leading-7 break-words whitespace-pre-wrap">
{ticket.description}
</p>
</Card>
<TicketEvents id={id} />
<Conversation
key={id}
id={id}
closed={ticket.status === 'CLOSED'}
/>
</div>
<div className="space-y-6">
<Card title="工单信息">
<Descriptions
column={1}
size="small"
items={[
{
key: 'status',
label: '当前状态',
children: (
<Tag color={statusColors[ticket.status]}>
{statusLabels[ticket.status]}
</Tag>
),
},
{
key: 'priority',
label: '优先级',
children: (
<Tag color={priorityColors[ticket.priority]}>
{priorityLabels[ticket.priority]}
</Tag>
),
},
{
key: 'assignee',
label: '负责人',
children: ticket.assignee?.user.name ?? '未分配',
},
{
key: 'created',
label: '创建时间',
children: formatTime(ticket.createdAt),
},
{
key: 'updated',
label: '更新时间',
children: formatTime(ticket.updatedAt),
},
]}
/>
</Card>
{team.role !== 'CUSTOMER' && (
<ReminderSettings
id={id}
version={ticket.version}
dueAt={ticket.dueAt}
inactive={['RESOLVED', 'CLOSED'].includes(ticket.status)}
/>
)}
{team.role !== 'CUSTOMER' && (
<Assignment
key={`${id}:${ticket.version}`}
id={id}
version={ticket.version}
closed={ticket.status === 'CLOSED'}
assigneeId={ticket.assigneeMembershipId}
/>
)}
{team.role !== 'CUSTOMER' && (
<Card title="更新状态">
{allowedTransitions[ticket.status].length === 0 ? (
<p className="text-slate-500">
工单已关闭,无可用状态操作。
</p>
) : (
<div className="space-y-3">
<Select
aria-label="目标状态"
className="w-full"
placeholder="选择目标状态"
value={nextStatus}
disabled={update.isPending}
onChange={setNextStatus}
options={allowedTransitions[ticket.status].map(
(value) => ({ value, label: statusLabels[value] }),
)}
/>
<Input.TextArea
aria-label="状态操作说明"
rows={3}
maxLength={1000}
showCount
placeholder={
nextStatus &&
statusNeedsReason(ticket.status, nextStatus)
? '请填写操作说明(必填)'
: '操作说明(选填)'
}
value={reason}
disabled={update.isPending}
onChange={(event) => setReason(event.target.value)}
/>
<p className="text-xs text-slate-500">
解决、关闭或重新打开工单时必须填写说明。
</p>
<Button
type="primary"
block
disabled={
!nextStatus ||
(statusNeedsReason(ticket.status, nextStatus) &&
!reason.trim())
}
loading={update.isPending}
onClick={() => {
if (nextStatus) {
const issue = statusChangeIssue(
ticket.status,
nextStatus,
reason,
);
if (issue) {
void message.warning(issue);
return;
}
update.mutate({
id,
teamId: team.id,
status: nextStatus,
version: ticket.version,
reason,
});
}
}}
>
保存状态
</Button>
</div>
)}
</Card>
)}
</div>
</div>
</>
)}
</Frame>
);
}