TTeamDesk 学习手册

src/features/tickets/collaboration.tsx

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

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

搜索教程

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

阅读设置

外观
正文字号

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

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