TTeamDesk 学习手册

src/features/tickets/workspace.tsx

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

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

搜索教程

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

阅读设置

外观
正文字号

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

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