TTeamDesk 学习手册

src/features/tasks/workspace.tsx

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

tsx
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
  Alert,
  App,
  Button,
  Card,
  Empty,
  Progress,
  Space,
  Table,
  Tag,
} from 'antd';
import dayjs from 'dayjs';
import { useTRPC } from '@/trpc/client';
import { useTeam } from '@/features/teams/team-workspace';
import { taskKindLabels, taskStatusLabels } from '@/shared/tasks';

export function TasksWorkspace() {
  const team = useTeam();
  const trpc = useTRPC();
  const cache = useQueryClient();
  const { message } = App.useApp();
  const [page, setPage] = useState(1);
  const tasks = useQuery(
    trpc.task.list.queryOptions(
      { teamId: team.id, page, pageSize: 10 },
      { refetchInterval: 2500 },
    ),
  );
  const health = useQuery(
    trpc.task.health.queryOptions(
      { teamId: team.id },
      { refetchInterval: 10000 },
    ),
  );
  const notifications = useQuery(
    trpc.task.notifications.queryOptions(
      { teamId: team.id },
      { refetchInterval: 5000 },
    ),
  );
  const retry = useMutation(
    trpc.task.retry.mutationOptions({
      onSuccess() {
        void cache.invalidateQueries({ queryKey: trpc.task.list.queryKey() });
        void message.success('已受理重试');
      },
      onError(error) {
        void message.error(error.message);
      },
    }),
  );
  const read = useMutation(
    trpc.task.read.mutationOptions({
      onSuccess() {
        void cache.invalidateQueries({
          queryKey: trpc.task.notifications.queryKey(),
        });
      },
      onError(error) {
        void message.error(error.message);
      },
    }),
  );
  return (
    <main className="mx-auto max-w-6xl space-y-6 px-4 py-8 md:px-8">
      <div>
        <h1 className="mb-2 text-3xl font-semibold">任务与提醒</h1>
        <p className="text-slate-500">
          导出按提交时的筛选条件执行,文件保留 24
          小时。可以离开页面,任务仍会继续。
        </p>
      </div>
      {health.isPending ? (
        <Tag>正在检查任务服务…</Tag>
      ) : health.isError ? (
        <Alert type="warning" title="无法读取任务服务状态" />
      ) : health.data && (!health.data.redis || !health.data.worker) ? (
        <Alert
          type="warning"
          showIcon
          title={
            !health.data.redis
              ? 'Redis 暂不可用,任务已保留,恢复后继续处理'
              : 'Worker 未运行,已受理任务正在等待处理'
          }
        />
      ) : (
        <Tag color="success">任务服务运行中</Tag>
      )}
      {team.role !== 'CUSTOMER' && (
        <Card title="超时提醒(最近 30 条)">
          {notifications.isError && (
            <Alert type="error" title={notifications.error.message} />
          )}
          {notifications.data?.length === 0 && (
            <Empty description="暂无超时提醒" />
          )}
          <ul className="space-y-3">
            {notifications.data?.map((item) => (
              <li
                key={item.id}
                className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 p-3"
              >
                <div>
                  <Link href={`/tickets/${item.ticket.id}?team=${team.id}`}>
                    #{item.ticket.number} {item.ticket.title}
                  </Link>
                  <p className="mt-1 text-xs text-slate-500">
                    工单已超时 ·{' '}
                    {dayjs(item.createdAt).format('MM-DD HH:mm:ss')}
                  </p>
                </div>
                {item.readAt ? (
                  <Tag>已读</Tag>
                ) : (
                  <Button
                    loading={read.isPending}
                    onClick={() =>
                      read.mutate({ teamId: team.id, id: item.id })
                    }
                  >
                    标为已读
                  </Button>
                )}
              </li>
            ))}
          </ul>
        </Card>
      )}
      <Card
        title="后台任务"
        extra={
          <Button
            onClick={() => void tasks.refetch()}
            loading={tasks.isFetching}
          >
            刷新
          </Button>
        }
      >
        <p className="mb-4 text-sm text-slate-500">
          自动重试最多执行 3
          次,间隔逐步增加;最终失败后可以手动发起新一轮。超时提醒在指定时间到达后执行。
        </p>
        {tasks.isError && <Alert type="error" title={tasks.error.message} />}
        <Table
          rowKey="id"
          loading={tasks.isPending}
          dataSource={tasks.data?.items}
          scroll={{ x: 900 }}
          pagination={{
            current: page,
            total: tasks.data?.total,
            pageSize: 10,
            showSizeChanger: false,
            onChange: setPage,
          }}
          columns={[
            {
              title: '任务',
              key: 'kind',
              render: (_, task) => (
                <div>
                  {taskKindLabels[task.kind]}
                  {task.ticket && ` #${task.ticket.number}`}
                  <p className="mt-1 text-xs text-slate-500">
                    {dayjs(task.createdAt).format('MM-DD HH:mm:ss')}
                  </p>
                </div>
              ),
            },
            {
              title: '状态',
              key: 'status',
              render: (_, task) => (
                <Tag
                  color={
                    task.status === 'FAILED'
                      ? 'error'
                      : task.status === 'COMPLETED'
                        ? 'success'
                        : 'processing'
                  }
                >
                  {taskStatusLabels[task.status]}
                </Tag>
              ),
            },
            {
              title: '进度',
              key: 'progress',
              width: 150,
              render: (_, task) => (
                <Progress
                  percent={task.progress}
                  size="small"
                  status={task.status === 'FAILED' ? 'exception' : undefined}
                />
              ),
            },
            {
              title: '本轮执行',
              key: 'attempts',
              render: (_, task) => `${task.attempts}/3 · 第 ${task.run + 1} 轮`,
            },
            {
              title: '结果 / 预计执行',
              key: 'result',
              render: (_, task) => (
                <div className="max-w-72 break-words">
                  {task.resultSummary ??
                    task.lastError ??
                    dayjs(task.runAt).format('MM-DD HH:mm:ss')}
                  {task.expiresAt && (
                    <p className="mt-1 text-xs text-slate-500">
                      文件有效至 {dayjs(task.expiresAt).format('MM-DD HH:mm')}
                    </p>
                  )}
                </div>
              ),
            },
            {
              title: '操作',
              key: 'actions',
              render: (_, task) => (
                <Space>
                  {task.canDownload && (
                    <a href={`/api/exports/${task.id}`}>下载 CSV</a>
                  )}
                  {task.status === 'FAILED' && (
                    <Button
                      onClick={() =>
                        retry.mutate({ teamId: team.id, id: task.id })
                      }
                      loading={retry.isPending}
                    >
                      重试
                    </Button>
                  )}
                </Space>
              ),
            },
          ]}
        />
      </Card>
    </main>
  );
}

搜索教程

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

阅读设置

外观
正文字号

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

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