feat: refine frontend tables and task center
This commit is contained in:
@@ -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 作为验收依据。
|
||||
|
||||
|
||||
@@ -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: '审计记录' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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["'][^>]*>/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AuditPage } from '@/features/operations/AuditPage'
|
||||
|
||||
export const Route = createFileRoute('/_app/audit')({ component: AuditPage })
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user