src/features/tickets/collaboration.tsx
此代码来自构建时的仓库快照。修改源码后重新构建即可更新。
'use client';
import { useState } from 'react';
import {
Alert,
App,
Button,
Card,
Empty,
Input,
Pagination,
Select,
Spin,
Tag,
} from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useTRPC } from '@/trpc/client';
import { useTeam } from '@/features/teams/team-workspace';
import { roleLabels } from '@/shared/team';
import { replyContentSchema } from '@/shared/ticket';
import { draftKey } from '@/stores/drafts';
import { useDraftStore } from './draft-provider';
export function Assignment({
id,
version,
closed,
assigneeId,
}: {
id: string;
version: number;
closed: boolean;
assigneeId: string | null;
}) {
const team = useTeam();
const trpc = useTRPC();
const cache = useQueryClient();
const { message } = App.useApp();
const candidates = useQuery(
trpc.ticket.assignees.queryOptions({ teamId: team.id }),
);
const [selected, setSelected] = useState<string | null | undefined>();
const assign = useMutation(
trpc.ticket.assign.mutationOptions({
async onSuccess() {
setSelected(undefined);
await Promise.all([
cache.invalidateQueries({
queryKey: trpc.ticket.events.queryKey({ id, teamId: team.id }),
}),
cache.invalidateQueries({ queryKey: trpc.ticket.list.queryKey() }),
cache.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') {
setSelected(undefined);
void cache.invalidateQueries({
queryKey: trpc.ticket.detail.queryKey({ id, teamId: team.id }),
});
}
},
}),
);
return (
<Card title="工单分配">
{candidates.isError && (
<Alert
type="error"
title={candidates.error.message}
action={
<Button onClick={() => void candidates.refetch()}>重试</Button>
}
/>
)}
<Select
aria-label="工单负责人"
className="mb-3 w-full"
allowClear
placeholder="未分配"
loading={candidates.isPending}
disabled={closed || assign.isPending || candidates.isError}
value={(selected === undefined ? assigneeId : selected) ?? undefined}
options={candidates.data?.map((item) => ({
value: item.id,
label: `${item.user.name}${item.userId === team.userId ? '(我)' : ''}`,
}))}
onChange={(value) => setSelected(value ?? null)}
/>
<div className="flex gap-2">
<Button
type="primary"
disabled={closed || selected === undefined || assign.isPending}
loading={assign.isPending}
onClick={() => {
if (selected !== undefined)
assign.mutate({
id,
teamId: team.id,
version,
assigneeId: selected,
});
}}
>
保存分配
</Button>
<Button
disabled={
closed ||
assign.isPending ||
!candidates.data?.some((item) => item.userId === team.userId)
}
onClick={() =>
setSelected(
candidates.data!.find((item) => item.userId === team.userId)!.id,
)
}
>
分配给我
</Button>
</div>
{closed && (
<p className="mt-3 text-sm text-slate-500">已关闭工单不能修改分配。</p>
)}
</Card>
);
}
export function Conversation({ id, closed }: { id: string; closed: boolean }) {
const team = useTeam();
const trpc = useTRPC();
const cache = useQueryClient();
const { message } = App.useApp();
const [page, setPage] = useState(1);
const key = draftKey(team.id, id);
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 replies = useQuery(
trpc.ticket.replies.queryOptions(
{ id, teamId: team.id, page, pageSize: 20 },
{ refetchInterval: 15000 },
),
);
const send = useMutation(
trpc.ticket.reply.mutationOptions({
async onSuccess() {
remove(key);
setPage(1);
await Promise.all([
cache.invalidateQueries({
queryKey: trpc.ticket.events.queryKey({ id, teamId: team.id }),
}),
cache.invalidateQueries({
queryKey: trpc.ticket.replies.queryKey({ id, teamId: team.id }),
}),
cache.invalidateQueries({
queryKey: trpc.ticket.detail.queryKey({ id, teamId: team.id }),
}),
cache.invalidateQueries({ queryKey: trpc.ticket.list.queryKey() }),
]);
void message.success('回复已发送');
},
onError(error) {
void message.error(error.message);
void cache.invalidateQueries({
queryKey: trpc.ticket.detail.queryKey({ id, teamId: team.id }),
});
},
}),
);
function submit() {
const content = replyContentSchema.safeParse(draft?.text ?? '');
if (!content.success) {
void message.warning(content.error.issues[0].message);
return;
}
send.mutate({
id,
teamId: team.id,
content: content.data,
clientRequestId: draft?.requestId ?? crypto.randomUUID(),
});
}
return (
<Card
title="沟通记录"
extra={
<Button
loading={replies.isFetching}
onClick={() => void replies.refetch()}
>
刷新回复
</Button>
}
>
<p className="mb-4 text-sm text-slate-500">
最新回复在前,每 15 秒自动刷新。
</p>
{replies.isPending && <Spin />}
{replies.isError && (
<Alert
type="error"
title={replies.error.message}
action={<Button onClick={() => void replies.refetch()}>重试</Button>}
/>
)}
{!replies.isPending &&
!replies.isError &&
!replies.data?.items.length && <Empty description="暂无回复" />}
<div className="space-y-4">
{replies.data?.items.map((reply) => (
<article
key={reply.id}
className="rounded-lg border border-slate-200 p-4"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<strong>{reply.authorName}</strong>
<Tag>{roleLabels[reply.authorRole]}</Tag>
<span className="text-xs text-slate-500">
{dayjs(reply.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</span>
</div>
<p className="break-words whitespace-pre-wrap">{reply.content}</p>
</article>
))}
</div>
{!!replies.data?.total && (
<Pagination
className="mt-5"
current={page}
pageSize={20}
total={replies.data.total}
showSizeChanger={false}
onChange={setPage}
/>
)}
<div className="mt-6 border-t border-slate-200 pt-5">
{closed ? (
<Alert
type="info"
title="工单已关闭,不能继续回复。未发送草稿仍保留在当前浏览器会话。"
/>
) : (
<>
<Input.TextArea
aria-label="回复内容"
placeholder="输入回复内容…"
rows={5}
maxLength={10000}
showCount
disabled={!hydrated || send.isPending}
value={draft?.text ?? ''}
onChange={(event) => {
const text = event.target.value;
if (!text) remove(key);
else save(key, { text, requestId: crypto.randomUUID() });
}}
/>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-slate-500">
草稿仅保留于当前浏览器会话,退出登录后清除。
</span>
<div className="flex gap-2">
<Button
disabled={!draft || send.isPending}
onClick={() => remove(key)}
>
清空草稿
</Button>
<Button
type="primary"
loading={send.isPending}
disabled={!hydrated || !draft?.text?.trim()}
onClick={submit}
>
发送回复
</Button>
</div>
</div>
</>
)}
</div>
</Card>
);
}