feat: refine frontend tables and task center

This commit is contained in:
2026-09-20 18:23:40 +08:00
parent 59f170c191
commit e79bd7425b
21 changed files with 143 additions and 399 deletions
@@ -32,8 +32,7 @@
{"name": "environment", "path": "/browsers", "heading": "运行环境", "status": "passed"},
{"name": "competitors", "path": "/creator/competitors", "heading": "竞品分析", "status": "passed", "shareUrl": "https://www.douyin.com/video/7685553258340830507", "preview": "anonymous author preview confirmed", "monitoring": "listener created and enabled", "temporaryRuntimeCleanup": "verified"},
{"name": "workbench", "path": "/creator/workbench", "heading": "运营工作台", "status": "passed"},
{"name": "tasks", "path": "/tasks", "heading": "任务中心", "status": "passed"},
{"name": "audit", "path": "/audit", "heading": "审计记录", "status": "passed"}
{"name": "tasks", "path": "/tasks", "heading": "任务中心", "status": "passed"}
],
"interactiveActions": {
"accountOpenDetail": {"status": "passed", "resultPath": "/accounts/account-committed"},
@@ -46,8 +45,7 @@
},
"competitorPreview": {"status": "passed", "result": "anonymous Douyin author preview confirmed and listener created", "shareUrl": "https://www.douyin.com/video/7685553258340830507", "temporaryRuntimeCleanup": "only stopped plan01-douyin remained after cleanup"},
"workbenchTabs": {"status": "passed", "tabs": ["线索", "规则", "事件监听", "私信", "操作记录", "评论"]},
"taskFilterApplyClear": {"status": "passed", "value": "account-committed"},
"auditFilterExport": {"status": "passed", "event": "environment_action_finished", "export": "sanitized current page clicked"}
"taskFilterApplyClear": {"status": "passed", "value": "account-committed"}
},
"buildEvidence": {
"outputPath": "docs/evidence/tanstack-start-build-output.log",
@@ -63,7 +61,6 @@
"phaseAAccounts": 200,
"browsers": 200,
"creatorAccounts": 200,
"audit": 200,
"creatorUpdatesInitialEvent": "retry: 5000",
"healthAfterSseDisconnect": 204
},
@@ -27,7 +27,6 @@
| 竞品 | `/creator/competitors` | 使用 `https://www.douyin.com/video/7685553258340830507` 完成匿名解析,页面显示作者预览并确认作者,随后确认加入监听;API 验证新增启用监听记录,匿名 runtime 已释放并清理 |
| 工作台 | `/creator/workbench` | 显示评论、线索、规则、事件监听、私信和操作记录页签,无错误文案 |
| 任务 | `/tasks` | 显示任务筛选和空状态,无错误文案 |
| 审计 | `/audit` | 显示审计记录和脱敏导出入口,无错误文案 |
逐页加载检查确认:URL 与 TanStack 文件路由一致、主标题存在,正文不包含“控制面代理不可用”“网络请求失败”或“认证已过期”等通用错误。实际操作产生的领域提示(账号详情说明、竞品无可用已登录账号、gateway 启动失败)均保留在下方操作记录,不被当成成功。
@@ -39,7 +38,6 @@
- `/api/phase-a/accounts``200`
- `/api/browsers``200`
- `/api/creator/accounts``200`
- `/api/phase-a/audit``200`
- `/api/creator/updates`:通过前端 `/api` 代理收到初始 `retry: 5000` SSE 事件;测试客户端主动超时断开后 control-plane 仍返回 `/healthz 204`
本次联调还覆盖了真实 `/api` 代理,而不是 Playwright mock。Playwright 仍只作为既有响应式回归测试,不作为本记录的真实 control-plane 证据。
@@ -53,7 +51,6 @@
- 竞品:打开“添加监听”,输入 `https://www.douyin.com/video/7685553258340830507`,点击“解析并预览作者”,页面显示作者预览;点击“确认作者并加入队列”,再点击“确认加入监听”,页面显示“相关账号已加入监听队列。”。control-plane API 验证新记录为启用状态;gateway 列表确认只剩已停止的 `plan01-douyin`,没有匿名 runtime。
- 工作台:实际切换“线索、规则、事件监听、私信、操作记录、评论”六个页签,并分别等待内容加载。
- 任务:实际填写并清除账号筛选 `account-committed`,两次列表请求均完成。
- 审计:实际填写事件筛选 `environment_action_finished`,看到真实审计记录,并点击“导出当前页(脱敏)”。
早期记录中的旧 Docker gateway 与临时 native gateway 结果保留作历史诊断;最终复核已将登记 gateway 指向可用的 native `127.0.0.1:28187`,并在该登记配置上完成启停录制,不再以临时未登记 gateway 作为验收依据。
-1
View File
@@ -28,7 +28,6 @@ const menu = [
{ to: '/creator/workbench', icon: 'ri-chat-3-line', label: '运营工作台' },
{ to: '/creator/settings', icon: 'ri-settings-3-line', label: '采集设置' },
{ to: '/tasks', icon: 'ri-task-line', label: '任务中心' },
{ to: '/audit', icon: 'ri-history-line', label: '审计记录' },
],
},
{
+3 -3
View File
@@ -540,7 +540,7 @@ export function AccountList() {
<TableHead>平台</TableHead>
<TableHead>标签</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -573,8 +573,8 @@ export function AccountList() {
<StatusPill tone={account.enabled ? "success" : "neutral"} label={account.enabled ? "监测中" : "已暂停"} />
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" onClick={() => openTags(account)}>编辑标签</Button>
{account.source_type === "owned" ? (
<>
@@ -487,77 +487,46 @@ export function CreatorCompetitorsPage() {
<div className="w-full">
<Card>
<CardContent>
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold">作品与指标</h2>
<p className="mt-1 text-sm text-muted-foreground">
时间窗口指标阈值和不可用字段由后端原样保留
</p>
<p className="mt-1 text-sm text-muted-foreground">时间窗口指标阈值和不可用字段由后端原样保留</p>
</div>
<span className="text-xs text-muted-foreground"> {workPageInfo.total} </span>
</div>
<div className="mb-5 rounded-lg border border-border bg-muted/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">筛选作品</h3>
<span className="text-xs text-muted-foreground">调整条件后自动刷新</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field id="work-platform-filter" label="平台">
<Select value={platform || "__all__"} onValueChange={(value) => setPlatform(value === "__all__" ? "" : value)}>
<SelectTrigger id="work-platform-filter"><SelectValue placeholder="全部平台" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部平台</SelectItem>
{platformOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field id="work-min-likes" label="最低点赞"><Input id="work-min-likes" type="number" min="0" value={minLikes} onChange={(event) => setMinLikes(event.target.value)} /></Field>
<Field id="work-min-comments" label="最低评论"><Input id="work-min-comments" type="number" min="0" value={minComments} onChange={(event) => setMinComments(event.target.value)} /></Field>
<Field id="work-min-shares" label="最低转发"><Input id="work-min-shares" type="number" min="0" value={minShares} onChange={(event) => setMinShares(event.target.value)} /></Field>
<Field id="work-status-filter" label="发布时间状态">
<Select value={publishedAtStatus || "__all__"} onValueChange={(value) => setPublishedAtStatus(value === "__all__" ? "" : value)}>
<SelectTrigger id="work-status-filter"><SelectValue placeholder="全部状态" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部状态</SelectItem>
<SelectItem value="verified">已核验</SelectItem>
<SelectItem value="future">未来时间</SelectItem>
<SelectItem value="unverified">待核验</SelectItem>
<SelectItem value="invalid">无效时间</SelectItem>
</SelectContent>
</Select>
</Field>
<Field id="work-published-after" label="发布时间从"><Input id="work-published-after" type="date" value={publishedAfter} onChange={(event) => setPublishedAfter(event.target.value)} /></Field>
<Field id="work-published-before" label="发布时间至"><Input id="work-published-before" type="date" value={publishedBefore} onChange={(event) => setPublishedBefore(event.target.value)} /></Field>
</div>
<Field id="work-platform-filter" label="平台">
<Select value={platform || "__all__"} onValueChange={(value) => setPlatform(value === "__all__" ? "" : value)}>
<SelectTrigger id="work-platform-filter"><SelectValue placeholder="全部平台" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部平台</SelectItem>
{platformOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field id="work-min-likes" label="最低点赞">
<Input
id="work-min-likes"
type="number"
min="0"
value={minLikes}
onChange={(event) => setMinLikes(event.target.value)}
/>
</Field>
<Field id="work-min-comments" label="最低评论">
<Input
id="work-min-comments"
type="number"
min="0"
value={minComments}
onChange={(event) => setMinComments(event.target.value)}
/>
</Field>
<Field id="work-min-shares" label="最低转发">
<Input
id="work-min-shares"
type="number"
min="0"
value={minShares}
onChange={(event) => setMinShares(event.target.value)}
/>
</Field>
<Field id="work-status-filter" label="发布时间状态">
<Select value={publishedAtStatus || "__all__"} onValueChange={(value) => setPublishedAtStatus(value === "__all__" ? "" : value)}>
<SelectTrigger id="work-status-filter"><SelectValue placeholder="全部状态" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部状态</SelectItem>
<SelectItem value="verified">已核验</SelectItem>
<SelectItem value="future">未来时间</SelectItem>
<SelectItem value="unverified">待核验</SelectItem>
<SelectItem value="invalid">无效时间</SelectItem>
</SelectContent>
</Select>
</Field>
<Field id="work-published-after" label="发布时间从">
<Input
id="work-published-after"
type="date"
value={publishedAfter}
onChange={(event) => setPublishedAfter(event.target.value)}
/>
</Field>
<Field id="work-published-before" label="发布时间至">
<Input
id="work-published-before"
type="date"
value={publishedBefore}
onChange={(event) => setPublishedBefore(event.target.value)}
/>
</Field>
</div>
<PageState
pending={pending}
@@ -573,7 +542,7 @@ export function CreatorCompetitorsPage() {
<TableHead>发布时间</TableHead>
<TableHead>点赞 / 评论 / 转发</TableHead>
<TableHead>指标下次采集</TableHead>
<TableHead />
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -588,7 +557,7 @@ export function CreatorCompetitorsPage() {
<TableCell className="text-xs text-muted-foreground">{work.published_at ? dateTime(work.published_at) : "待核验"}</TableCell>
<TableCell className="text-xs">{work.likes ?? "—"} / {work.comments_count ?? "—"} / {work.shares ?? "—"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{work.next_metric_at ? dateTime(work.next_metric_at) : work.metric_stop_reason || "—"}</TableCell>
<TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>详情</Button>
<Button size="sm" onClick={() => selectMaterial(work.id)}>选择素材</Button>
@@ -294,7 +294,7 @@ export function BrowserVersionList() {
<TableHead>浏览器路径</TableHead>
<TableHead>备注</TableHead>
<TableHead>启用</TableHead>
<TableHead>操作</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -312,8 +312,8 @@ export function BrowserVersionList() {
onCheckedChange={() => toggleBrowserVersion(version)}
/>
</TableCell>
<TableCell>
<div className="flex gap-1">
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${version.version}`} disabled={busyAlias === version.version} onClick={() => setEditing({ ...version, error: "" })}>
<i className="ri-edit-line" aria-hidden="true" />
</Button>
@@ -200,7 +200,7 @@ function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
return (
<div className="hidden overflow-x-auto rounded-lg border border-border bg-card md:block">
<Table>
<TableHeader><TableRow><TableHead>环境</TableHead><TableHead>状态</TableHead><TableHead>固定绑定</TableHead><TableHead>CDP</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
<TableHeader><TableRow><TableHead>环境</TableHead><TableHead>状态</TableHead><TableHead>固定绑定</TableHead><TableHead>CDP</TableHead><TableHead className="text-right">操作</TableHead></TableRow></TableHeader>
<TableBody>
{runtimes.map((runtime) => (
<TableRow key={runtime.alias}>
@@ -215,7 +215,7 @@ function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
<div className="mt-1"><SchedulePill runtime={runtime} /></div>
</TableCell>
<TableCell>{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <span className="text-sm text-muted-foreground"></span>}</TableCell>
<TableCell><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></TableCell>
<TableCell className="text-right"><div className="flex justify-end"><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></div></TableCell>
</TableRow>
))}
</TableBody>
@@ -278,7 +278,7 @@ export function GatewayList() {
<TableHead>名称</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>令牌</TableHead>
<TableHead>操作</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -287,13 +287,15 @@ export function GatewayList() {
<TableCell><span className="font-semibold">{gateway.name}</span></TableCell>
<TableCell><code className="anywhere font-mono text-[13px]">{gateway.endpoint}</code></TableCell>
<TableCell><TokenCell token={gateway.token} name={gateway.name} /></TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${gateway.name}`} disabled={busy === gateway.name || busy === "edit"} onClick={() => { setEditing(gateway); setEditError(null); }}>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" aria-label={`编辑 ${gateway.name}`} disabled={busy === gateway.name || busy === "edit"} onClick={() => { setEditing(gateway); setEditError(null); }}>
<i className="ri-edit-line" aria-hidden="true" />
编辑
</Button>
<Button variant="destructive" size="icon-sm" aria-label={`删除 ${gateway.name}`} disabled={busy === gateway.name || busy === "edit"} onClick={() => setDeleting(gateway)}>
<Button variant="destructive" size="sm" aria-label={`删除 ${gateway.name}`} disabled={busy === gateway.name || busy === "edit"} onClick={() => setDeleting(gateway)}>
<i className="ri-delete-bin-line" aria-hidden="true" />
删除
</Button>
</div>
</TableCell>
@@ -217,6 +217,18 @@ describe("GatewayList", () => {
);
});
it("renders labeled actions in a right-aligned operation cell", async () => {
renderGateways(provider())
const table = await screen.findByRole("table")
expect(within(table).getByRole("columnheader", { name: "操作" }).className).toContain("text-right")
const edit = within(table).getByRole("button", { name: "编辑 gw-1" })
const remove = within(table).getByRole("button", { name: "删除 gw-1" })
expect(edit.textContent).toContain("编辑")
expect(remove.textContent).toContain("删除")
expect(edit.closest("td").className).toContain("text-right")
})
it("does not render live gateway status in the list", async () => {
renderGateways(provider());
@@ -268,7 +268,7 @@ function ExitTable({
<TableHead>出口IP</TableHead>
<TableHead>最近检测</TableHead>
<TableHead>绑定账号</TableHead>
<TableHead>操作</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -290,8 +290,8 @@ function ExitTable({
<TableCell>
{bindingsError ? <span className="text-muted-foreground">状态未知</span> : <span className="anywhere">{accounts?.length ? accounts.join("、") : "未绑定"}</span>}
</TableCell>
<TableCell>
<ExitActions exit={exit} busy={busy === exit.id} onAction={onAction} onEdit={onEdit} onDelete={onDelete} />
<TableCell className="text-right">
<div className="flex justify-end"><ExitActions exit={exit} busy={busy === exit.id} onAction={onAction} onEdit={onEdit} onDelete={onDelete} /></div>
</TableCell>
</TableRow>
);
@@ -588,11 +588,6 @@ export function NetworkExitDetail() {
],
]}
/>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link to={`/audit?network_exit_id=${encodeURIComponent(exit.id)}`}>查看审计</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
-146
View File
@@ -1,146 +0,0 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from '../../shared/router.jsx'
import { useList } from '../../shared/hooks/dataHooks.js'
import { Alert } from '../../components/ui/alert'
import { Button } from '../../components/ui/button'
import { Input } from '../../components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'
import { PageHeader, PageState } from '../../shared/ui/ui.jsx'
import { useTitle } from '../../shared/hooks/hooks.js'
const startOfDay = value => (value ? new Date(`${value}T00:00:00`).toISOString() : '')
const endOfDay = value => (value ? new Date(`${value}T23:59:59.999`).toISOString() : '')
const csvColumns = ['id', 'event_type', 'account_id', 'task_id', 'attempt_id', 'browser_env_alias', 'network_exit_id', 'binding_version', 'reason_code', 'outcome', 'created_at']
const csvCell = value => `"${String(value ?? '').replaceAll('"', '""')}"`
// 导出仅含关联字段的脱敏白名单,不带 details。
export function auditCSV(events) {
return [csvColumns, ...events.map(event => csvColumns.map(column => event[column]))]
.map(row => row.map(csvCell).join(','))
.join('\n')
}
function downloadCSV(events) {
const url = URL.createObjectURL(new Blob([auditCSV(events)], { type: 'text/csv;charset=utf-8' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'creatorhub-audit.csv'
anchor.click()
URL.revokeObjectURL(url)
}
function FilterField({ id, label, ...props }) {
return (
<div className="min-w-0 space-y-1">
<label htmlFor={id} className="block text-sm font-medium text-foreground">{label}</label>
<Input id={id} {...props} />
</div>
)
}
export function AuditPage() {
const [searchParams] = useSearchParams()
const [account, setAccount] = useState(searchParams.get('account_id') || '')
const [task, setTask] = useState(searchParams.get('task_id') || '')
const [attempt, setAttempt] = useState(searchParams.get('attempt_id') || '')
const [env, setEnv] = useState(searchParams.get('browser_env_alias') || '')
const [exit, setExit] = useState(searchParams.get('network_exit_id') || '')
const [eventType, setEventType] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [page, setPage] = useState(1)
const filters = [
{ field: 'account_id', value: account },
{ field: 'task_id', value: task },
{ field: 'attempt_id', value: attempt },
{ field: 'browser_env_alias', value: env },
{ field: 'network_exit_id', value: exit },
{ field: 'event_type', value: eventType },
{ field: 'from', value: startOfDay(from) },
{ field: 'to', value: endOfDay(to) },
]
const { result, query } = useList({ resource: 'audit', pagination: { currentPage: page, pageSize: 25 }, filters, queryOptions: { retry: false } })
const error = query.error
const isPending = query.isPending
const events = result.data ?? []
const total = result.total ?? 0
useTitle('CreatorHub · 审计记录')
useEffect(() => { setPage(1) }, [account, task, attempt, env, exit, eventType, from, to])
const hasFilters = !!(account || task || attempt || env || exit || eventType || from || to)
return (
<div>
<PageHeader icon="ri-history-line" title="审计记录" description="追加式事件回溯;页面不提供编辑或删除">
<Button disabled={!events.length} onClick={() => downloadCSV(events)}>
<i className="ri-download-2-line" aria-hidden="true" />
导出当前页脱敏
</Button>
</PageHeader>
<div className="mb-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<FilterField id="audit-account" label="账号" value={account} onChange={event => setAccount(event.target.value)} />
<FilterField id="audit-task" label="任务" value={task} onChange={event => setTask(event.target.value)} />
<FilterField id="audit-attempt" label="Attempt" value={attempt} onChange={event => setAttempt(event.target.value)} />
<FilterField id="audit-env" label="环境" value={env} onChange={event => setEnv(event.target.value)} />
<FilterField id="audit-exit" label="出口" value={exit} onChange={event => setExit(event.target.value)} />
<FilterField id="audit-event" label="事件" value={eventType} onChange={event => setEventType(event.target.value)} />
<FilterField id="audit-from" label="开始日期" type="date" value={from} onChange={event => setFrom(event.target.value)} />
<FilterField id="audit-to" label="结束日期" type="date" value={to} onChange={event => setTo(event.target.value)} />
</div>
{hasFilters ? (
<div className="mb-4">
<Button size="sm" onClick={() => { setAccount(''); setTask(''); setAttempt(''); setEnv(''); setExit(''); setEventType(''); setFrom(''); setTo('') }}>清除筛选</Button>
</div>
) : null}
{error ? <Alert variant="destructive" className="mb-4">{error.message}</Alert> : null}
<PageState pending={isPending} error={error} empty={events.length === 0} emptyText={hasFilters ? '没有符合筛选条件的审计事件' : '尚无审计事件'}>
<Table>
<TableHeader>
<TableRow>
<TableHead>事件</TableHead>
<TableHead>关联</TableHead>
<TableHead>环境 / 出口</TableHead>
<TableHead>原因 / 时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map(event => (
<TableRow key={event.id}>
<TableCell>
<p className="anywhere font-semibold">{event.event_type}</p>
<p className="text-xs text-muted-foreground">#{event.id}</p>
</TableCell>
<TableCell>
<div className="space-y-0.5 text-sm">
{event.account_id ? <p className="anywhere">账号<Link to={`/accounts/${event.account_id}`} className="text-primary hover:underline">{event.account_id}</Link></p> : null}
{event.task_id ? <p className="anywhere">任务<Link to={`/tasks/${event.task_id}`} className="text-primary hover:underline">{event.task_id}</Link></p> : null}
{event.attempt_id ? <p className="anywhere">Attempt<Link to={`/attempts/${event.attempt_id}`} className="text-primary hover:underline">{event.attempt_id}</Link></p> : null}
</div>
</TableCell>
<TableCell>
<p className="anywhere text-sm">
{event.browser_env_alias ? <Link to={`/browsers/${encodeURIComponent(event.browser_env_alias)}`} className="text-primary hover:underline">{event.browser_env_alias}</Link> : '—'}
{' / '}
{event.network_exit_id ? <Link to={`/network-exits/${encodeURIComponent(event.network_exit_id)}`} className="text-primary hover:underline">{event.network_exit_id}</Link> : '—'}
</p>
<p className="text-xs text-muted-foreground">绑定版本 {event.binding_version || '—'}</p>
</TableCell>
<TableCell>
<p className="anywhere text-sm">{event.reason_code || event.outcome || '—'}</p>
<p className="text-xs text-muted-foreground">{new Date(event.created_at).toLocaleString('zh-CN')}</p>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</PageState>
{total > 25 ? (
<div className="mt-4 flex items-center justify-end gap-4">
<Button size="sm" disabled={page === 1} onClick={() => setPage(current => current - 1)}>上一页</Button>
<p className="text-sm text-muted-foreground"> {page} / {Math.ceil(total / 25)} </p>
<Button size="sm" disabled={page * 25 >= total} onClick={() => setPage(current => current + 1)}>下一页</Button>
</div>
) : null}
</div>
)
}
@@ -1,62 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { TestDataProvider } from '../../test/providerCompat.jsx'
import { MemoryRouter } from '../../test/routerCompat.jsx'
import { AuditPage, auditCSV } from './AuditPage'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
const httpQuery = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
function renderAudit(dataProvider, initialEntry = '/audit?task_id=task-a') {
return render(
<QueryClientProvider client={httpQuery()}>
<TestDataProvider dataProvider={dataProvider}>
<MemoryRouter initialEntries={[initialEntry]}><AuditPage /></MemoryRouter>
</TestDataProvider>
</QueryClientProvider>,
)
}
describe('AuditPage', () => {
it('deep-links safe correlation fields and exposes no mutation or secret details', async () => {
const dataProvider = {
getList: vi.fn().mockResolvedValue({ data: [{
id: 7, event_type: 'task_verified', account_id: 'account-a', task_id: 'task-a', attempt_id: 'attempt-a',
browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4, reason_code: 'manual_verification_recorded',
details: { token: 'must-not-render' }, created_at: '2026-08-30T00:00:00Z',
}], total: 1 }),
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
}
renderAudit(dataProvider)
expect((await screen.findAllByRole('link', { name: 'task-a' })).length).toBeGreaterThan(0)
expect(screen.getAllByRole('link', { name: 'account-a' })[0].getAttribute('href')).toBe('/accounts/account-a')
expect(screen.getAllByRole('link', { name: 'attempt-a' })[0].getAttribute('href')).toBe('/attempts/attempt-a')
expect(screen.getAllByRole('link', { name: 'env-a' })[0].getAttribute('href')).toBe('/browsers/env-a')
expect(screen.getAllByRole('link', { name: 'exit-a' })[0].getAttribute('href')).toBe('/network-exits/exit-a')
expect(screen.queryByText('must-not-render')).toBeNull()
expect(screen.queryByRole('button', { name: /编辑|删除/ })).toBeNull()
})
it('exports only allowlisted correlation columns and requests the next page', async () => {
const event = {
id: 7, event_type: 'task_verified', task_id: 'task-a', attempt_id: 'attempt-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a',
details: { api_key: 'api-secret', private_key: 'private-secret', credential_key: 'credential-secret', authorization_header: 'auth-secret', proxy_url: 'proxy-secret' },
created_at: '2026-08-30T00:00:00Z',
}
const csv = auditCSV([event])
expect(csv).toContain('attempt-a')
for (const secret of ['api-secret', 'private-secret', 'credential-secret', 'auth-secret', 'proxy-secret']) expect(csv).not.toContain(secret)
const getList = vi.fn().mockImplementation(() => Promise.resolve({ data: [{ ...event, id: 7 }], total: 26 }))
const dataProvider = {
getList, getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
}
renderAudit(dataProvider, '/audit')
fireEvent.click(await screen.findByRole('button', { name: '下一页' }))
await waitFor(() => expect(getList.mock.calls.some(([{ pagination }]) => (pagination?.currentPage || pagination?.page) === 2)).toBe(true))
})
})
@@ -135,7 +135,6 @@ export function DraftPage() {
<div className="rounded-md border border-border p-3">
<p className="font-semibold">确认快照 v{confirmation.version}</p>
<p className="text-sm">账号版本 {confirmation.account_version} · 草稿版本 {confirmation.draft_version}</p>
<p className="anywhere text-sm">环境{confirmation.browser_env_alias || '未记录'} · 出口{confirmation.network_exit_id || '未记录'} · 绑定版本{confirmation.binding_version || '未记录'}</p>
</div>
) : null}
</CardContent></Card>
+27 -23
View File
@@ -6,6 +6,7 @@ import { Button } from '../../components/ui/button'
import { Card, CardContent } from '../../components/ui/card'
import { Input } from '../../components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'
import { DetailList, Field, PageHeader, PageState, StatusPill } from '../../shared/ui/ui.jsx'
import { useTitle } from '../../shared/hooks/hooks.js'
@@ -29,23 +30,6 @@ function actionErrorText(error) {
return error?.message || '操作失败'
}
function TaskCard({ task }) {
return (
<Card>
<CardContent className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<Link to={`/tasks/${task.id}`} className="anywhere font-semibold text-primary hover:underline">{task.id}</Link>
<StatusPill tone={stateTone[task.state] || 'neutral'} label={stateLabel(task.state)} />
</div>
<div className="text-sm"><span className="text-muted-foreground">账号</span><Link to={`/accounts/${task.account_id}`} className="anywhere text-primary hover:underline">{task.account_id}</Link></div>
<div className="text-sm"><span className="text-muted-foreground">确认</span><span className="anywhere">{task.confirmation_id || '未确认'}</span></div>
<div className="anywhere text-xs text-muted-foreground">{holdLabel(task.hold_reason)}</div>
<p className="text-xs text-muted-foreground">{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}</p>
</CardContent>
</Card>
)
}
export function TaskList() {
const [accountFilter, setAccountFilter] = useState('')
const [stateFilter, setStateFilter] = useState('')
@@ -84,9 +68,32 @@ export function TaskList() {
</div>
{error ? <Alert variant="destructive" className="mb-4">{error.message}</Alert> : null}
<PageState pending={isPending} error={error} empty={tasks.length === 0} emptyText={accountFilter || stateFilter ? '没有符合筛选条件的任务' : '尚无任务'}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{tasks.map(task => <TaskCard key={task.id} task={task} />)}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>任务</TableHead>
<TableHead>账号</TableHead>
<TableHead>确认</TableHead>
<TableHead>状态</TableHead>
<TableHead>停止原因</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map(task => (
<TableRow key={task.id}>
<TableCell><Link to={`/tasks/${task.id}`} className="anywhere font-semibold text-primary hover:underline">{task.id}</Link></TableCell>
<TableCell><Link to={`/accounts/${task.account_id}`} className="anywhere text-primary hover:underline">{task.account_id}</Link></TableCell>
<TableCell className="anywhere">{task.confirmation_id || '未确认'}</TableCell>
<TableCell><StatusPill tone={stateTone[task.state] || 'neutral'} label={stateLabel(task.state)} /></TableCell>
<TableCell className="anywhere text-sm text-muted-foreground">{holdLabel(task.hold_reason)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}</TableCell>
<TableCell className="text-right"><Button size="sm" variant="outline" asChild><Link to={`/tasks/${task.id}`}>查看</Link></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</PageState>
</div>
)
@@ -142,9 +149,6 @@ export function TaskDetail() {
['停止原因', <span className="anywhere">{holdLabel(task.hold_reason)}</span>],
['人工核验', task.verification_result || '尚未记录'],
]} />
<div>
<Button size="sm" asChild><Link to={`/audit?task_id=${encodeURIComponent(task.id)}`}>查看关联审计</Link></Button>
</div>
</CardContent>
</Card>
<Card>
+12 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { TestDataProvider } from '../../test/providerCompat.jsx'
import { MemoryRouter, Route, Routes } from '../../test/routerCompat.jsx'
@@ -85,6 +85,17 @@ describe('TaskDetail', () => {
})
describe('TaskList', () => {
it('keeps task details and the operation aligned in the table', async () => {
renderTasks(provider())
const table = await screen.findByRole('table')
expect(within(table).getByRole('link', { name: 'task-a' })).toBeTruthy()
expect(within(table).getByRole('link', { name: 'account-a' })).toBeTruthy()
expect(within(table).getByText('confirmation-a')).toBeTruthy()
expect(within(table).getByRole('columnheader', { name: '操作' }).className).toContain('text-right')
expect(within(table).getByRole('link', { name: '查看' }).closest('td').className).toContain('text-right')
})
it('filters tasks by account and state through the data provider', async () => {
const dataProvider = provider()
renderTasks(dataProvider)
@@ -31,7 +31,6 @@ export function AttemptDetail() {
]} />
<div className="flex flex-wrap gap-3">
<Button asChild><Link to={`/tasks/${encodeURIComponent(attempt.task_id)}`}>返回任务</Link></Button>
<Button asChild><Link to={`/audit?attempt_id=${encodeURIComponent(attempt.id)}`}>查看审计</Link></Button>
{attempt.browser_env_alias ? <Button asChild><Link to={`/browsers/${encodeURIComponent(attempt.browser_env_alias)}`}>查看环境</Link></Button> : null}
{attempt.network_exit_id ? <Button asChild><Link to={`/network-exits/${encodeURIComponent(attempt.network_exit_id)}`}>查看出口</Link></Button> : null}
</div>
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const featureDir = dirname(fileURLToPath(import.meta.url));
const tableFiles = [
"accounts/AccountsPage.jsx",
"creator/competitors/CreatorCompetitorsPage.jsx",
"environments/BrowserVersionsPage.jsx",
"environments/BrowsersPage.jsx",
"environments/GatewaysPage.jsx",
"environments/NetworkExitsPage.jsx",
"operations/TasksPage.jsx",
];
describe("table operation columns", () => {
it("keeps every operation column right aligned", () => {
for (const relativePath of tableFiles) {
const source = readFileSync(resolve(featureDir, relativePath), "utf8");
expect(source, relativePath).toMatch(
/<TableHead[^>]*className=["']text-right["'][^>]*><\/TableHead>/s,
);
expect(source, relativePath).toMatch(
/<TableCell className=["']text-right["'][^>]*>/,
);
}
});
});
-4
View File
@@ -1,4 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { AuditPage } from '@/features/operations/AuditPage'
export const Route = createFileRoute('/_app/audit')({ component: AuditPage })
+1 -12
View File
@@ -56,7 +56,6 @@ const resourcePaths = {
confirmations: "/phase-a/confirmations",
tasks: "/phase-a/tasks",
attempts: "/phase-a/attempts",
audit: "/phase-a/audit",
"network-exits": "/network-exits",
"creator-accounts": "/creator/accounts",
"creator-competitors": "/creator/competitors",
@@ -97,7 +96,7 @@ export const dataProvider = {
valueOf(field) !== undefined ? [[field, valueOf(field)]] : [],
),
);
if (resource === "audit" || resource.startsWith("creator-")) {
if (resource.startsWith("creator-")) {
const currentPage = pagination.currentPage || pagination.page;
const pageSize = pagination.pageSize || pagination.perPage;
if (currentPage || pageSize) {
@@ -309,16 +308,6 @@ function filterKeys(resource) {
drafts: ["account_id"],
confirmations: ["draft_id"],
tasks: ["account_id", "draft_id", "state"],
audit: [
"account_id",
"task_id",
"attempt_id",
"browser_env_alias",
"network_exit_id",
"event_type",
"from",
"to",
],
"creator-competitors": ["platform"],
"creator-works": [
"platform",
-32
View File
@@ -130,38 +130,6 @@ describe("dataProvider", () => {
);
});
it("maps paginated audit filters without exposing a CRUD mutation", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(
'{"data":[{"id":7,"event_type":"task_verified"}],"total":31}',
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetch);
await expect(
dataProvider.getList({
resource: "audit",
pagination: { currentPage: 2, pageSize: 25 },
filters: [
{ field: "task_id", value: "task-a" },
{ field: "attempt_id", value: "attempt-a" },
{ field: "browser_env_alias", value: "env-a" },
{ field: "network_exit_id", value: "exit-a" },
],
}),
).resolves.toEqual({
data: [{ id: 7, event_type: "task_verified" }],
total: 31,
});
expect(fetch).toHaveBeenCalledWith(
"/api/phase-a/audit?task_id=task-a&attempt_id=attempt-a&browser_env_alias=env-a&network_exit_id=exit-a&page=2&page_size=25",
{ headers: {} },
);
});
it("keeps generated draft, confirmation and enqueue identifiers out of user input", async () => {
const fetch = vi
.fn()
+3 -17
View File
@@ -202,10 +202,9 @@ test('keeps draft review responsive and restores focus after dialog close and su
await expect(trigger).toBeFocused()
})
test('keeps task recovery and audit traceable without blind retry at 599px, 900px and 1280px', async ({ page }) => {
test('keeps task recovery traceable without blind retry at 599px, 900px and 1280px', async ({ page }) => {
let verified = false
let resumed = false
let auditPage = 0
const baseTask = {
id: 'task-a', account_id: 'account-a', draft_id: 'draft-a', confirmation_id: 'confirmation-a', state: 'needs_confirmation',
hold_reason: 'task_result_uncertain', browser_env_alias: 'env-a', network_exit_id: 'exit-a', runtime_instance_id: 'runtime-a', binding_version: 4,
@@ -231,15 +230,7 @@ test('keeps task recovery and audit traceable without blind retry at 599px, 900p
await page.route('**/api/phase-a/attempts/attempt-a', route => route.fulfill({ json: { ...baseTask.attempts[0], task_id: 'task-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4 } }))
await page.route('**/api/browsers/env-a', route => route.fulfill({ json: { alias: 'env-a', name: '店铺环境', account_id: 'account-a', browser_version: '148', binding_version: 4, runtime_instance_id: 'runtime-a', network_exit: { id: 'exit-a', health_status: 'healthy' } } }))
await page.route('**/api/network-exits/exit-a', route => route.fulfill({ json: { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', version: 2 } }))
await page.route('**/api/phase-a/audit*', route => {
const currentPage = Number(new URL(route.request().url()).searchParams.get('page') || 1)
auditPage = Math.max(auditPage, currentPage)
return route.fulfill({ json: { data: [{
id: 7, event_type: 'task_verified', account_id: 'account-a', task_id: 'task-a', attempt_id: 'attempt-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4, reason_code: 'manual_verification_recorded', created_at: '2026-08-30T00:01:00Z',
}], total: 26, page: currentPage, page_size: 25 } })
})
for (const path of ['/tasks', '/tasks/task-a', '/attempts/attempt-a', '/browsers/env-a', '/network-exits/exit-a', '/audit?task_id=task-a']) {
for (const path of ['/tasks', '/tasks/task-a', '/attempts/attempt-a', '/browsers/env-a', '/network-exits/exit-a']) {
for (const width of [599, 900, 1280]) {
await page.setViewportSize({ width, height: 900 })
await page.goto(path)
@@ -257,12 +248,7 @@ test('keeps task recovery and audit traceable without blind retry at 599px, 900p
await page.getByRole('button', { name: '按核验结论恢复排队' }).click()
await expect.poll(() => resumed).toBe(true)
await expect(page.getByText('已排队')).toBeVisible()
await page.getByRole('link', { name: '查看关联审计' }).click()
await expect(page).toHaveURL(/\/audit\?task_id=task-a$/)
await expect(page.getByRole('link', { name: 'task-a' }).first()).toBeVisible()
await page.getByRole('button', { name: '下一页' }).click()
await expect.poll(() => auditPage).toBe(2)
await page.getByRole('link', { name: 'attempt-a' }).first().click()
await page.goto('/attempts/attempt-a')
await expect(page).toHaveURL(/\/attempts\/attempt-a$/)
await expect(page.getByRole('link', { name: '返回任务' })).toHaveAttribute('href', '/tasks/task-a')
})