src/features/tasks/workspace.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>
);
}