diff --git a/web/src/features/environments/BrowsersPage.jsx b/web/src/features/environments/BrowsersPage.jsx
index 7acbdee..98d2844 100644
--- a/web/src/features/environments/BrowsersPage.jsx
+++ b/web/src/features/environments/BrowsersPage.jsx
@@ -200,7 +200,7 @@ function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
return (
- 环境状态固定绑定CDP操作
+ 环境状态固定绑定CDP操作
{runtimes.map((runtime) => (
@@ -215,7 +215,7 @@ function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
{runtime.endpoint ? : —}
-
+
))}
diff --git a/web/src/features/environments/GatewaysPage.jsx b/web/src/features/environments/GatewaysPage.jsx
index 92fc9a6..0ad87c7 100644
--- a/web/src/features/environments/GatewaysPage.jsx
+++ b/web/src/features/environments/GatewaysPage.jsx
@@ -278,7 +278,7 @@ export function GatewayList() {
名称
Endpoint
令牌
- 操作
+ 操作
@@ -287,13 +287,15 @@ export function GatewayList() {
{gateway.name}
{gateway.endpoint}
-
-
-
diff --git a/web/src/features/operations/AuditPage.jsx b/web/src/features/operations/AuditPage.jsx
deleted file mode 100644
index 1918246..0000000
--- a/web/src/features/operations/AuditPage.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
- )
-}
-
-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 (
-
-
- downloadCSV(events)}>
-
- 导出当前页(脱敏)
-
-
-
- setAccount(event.target.value)} />
- setTask(event.target.value)} />
- setAttempt(event.target.value)} />
- setEnv(event.target.value)} />
- setExit(event.target.value)} />
- setEventType(event.target.value)} />
- setFrom(event.target.value)} />
- setTo(event.target.value)} />
-
- {hasFilters ? (
-
- { setAccount(''); setTask(''); setAttempt(''); setEnv(''); setExit(''); setEventType(''); setFrom(''); setTo('') }}>清除筛选
-
- ) : null}
- {error ?
{error.message} : null}
-
-
-
-
- 事件
- 关联
- 环境 / 出口
- 原因 / 时间
-
-
-
- {events.map(event => (
-
-
- {event.event_type}
- #{event.id}
-
-
-
- {event.account_id ?
账号:{event.account_id}
: null}
- {event.task_id ?
任务:{event.task_id}
: null}
- {event.attempt_id ?
Attempt:{event.attempt_id}
: null}
-
-
-
-
- {event.browser_env_alias ? {event.browser_env_alias} : '—'}
- {' / '}
- {event.network_exit_id ? {event.network_exit_id} : '—'}
-
- 绑定版本 {event.binding_version || '—'}
-
-
- {event.reason_code || event.outcome || '—'}
- {new Date(event.created_at).toLocaleString('zh-CN')}
-
-
- ))}
-
-
-
- {total > 25 ? (
-
-
setPage(current => current - 1)}>上一页
-
第 {page} / {Math.ceil(total / 25)} 页
-
= total} onClick={() => setPage(current => current + 1)}>下一页
-
- ) : null}
-
- )
-}
diff --git a/web/src/features/operations/AuditPage.test.jsx b/web/src/features/operations/AuditPage.test.jsx
deleted file mode 100644
index 3c94a6e..0000000
--- a/web/src/features/operations/AuditPage.test.jsx
+++ /dev/null
@@ -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(
-
-
-
-
- ,
- )
-}
-
-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))
- })
-})
diff --git a/web/src/features/operations/DraftPage.jsx b/web/src/features/operations/DraftPage.jsx
index edb0cce..3bce686 100644
--- a/web/src/features/operations/DraftPage.jsx
+++ b/web/src/features/operations/DraftPage.jsx
@@ -135,7 +135,6 @@ export function DraftPage() {
确认快照 v{confirmation.version}
账号版本 {confirmation.account_version} · 草稿版本 {confirmation.draft_version}
-
环境:{confirmation.browser_env_alias || '未记录'} · 出口:{confirmation.network_exit_id || '未记录'} · 绑定版本:{confirmation.binding_version || '未记录'}
) : null}
diff --git a/web/src/features/operations/TasksPage.jsx b/web/src/features/operations/TasksPage.jsx
index 0b5f0fe..9db5943 100644
--- a/web/src/features/operations/TasksPage.jsx
+++ b/web/src/features/operations/TasksPage.jsx
@@ -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 (
-
-
-
- {task.id}
-
-
- 账号:{task.account_id}
- 确认:{task.confirmation_id || '未确认'}
- {holdLabel(task.hold_reason)}
- {new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}
-
-
- )
-}
-
export function TaskList() {
const [accountFilter, setAccountFilter] = useState('')
const [stateFilter, setStateFilter] = useState('')
@@ -84,9 +68,32 @@ export function TaskList() {
{error ? {error.message} : null}
-
- {tasks.map(task => )}
-
+
+
+
+ 任务
+ 账号
+ 确认
+ 状态
+ 停止原因
+ 更新时间
+ 操作
+
+
+
+ {tasks.map(task => (
+
+ {task.id}
+ {task.account_id}
+ {task.confirmation_id || '未确认'}
+
+ {holdLabel(task.hold_reason)}
+ {new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')}
+ 查看
+
+ ))}
+
+
)
@@ -142,9 +149,6 @@ export function TaskDetail() {
['停止原因', {holdLabel(task.hold_reason)}],
['人工核验', task.verification_result || '尚未记录'],
]} />
-
- 查看关联审计
-
diff --git a/web/src/features/operations/TasksPage.test.jsx b/web/src/features/operations/TasksPage.test.jsx
index ba7be25..52d2583 100644
--- a/web/src/features/operations/TasksPage.test.jsx
+++ b/web/src/features/operations/TasksPage.test.jsx
@@ -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)
diff --git a/web/src/features/operations/TracePages.jsx b/web/src/features/operations/TracePages.jsx
index 740ac6e..5d35163 100644
--- a/web/src/features/operations/TracePages.jsx
+++ b/web/src/features/operations/TracePages.jsx
@@ -31,7 +31,6 @@ export function AttemptDetail() {
]} />
返回任务
- 查看审计
{attempt.browser_env_alias ? 查看环境 : null}
{attempt.network_exit_id ? 查看出口 : null}
diff --git a/web/src/features/table-action-alignment.test.js b/web/src/features/table-action-alignment.test.js
new file mode 100644
index 0000000..25de68d
--- /dev/null
+++ b/web/src/features/table-action-alignment.test.js
@@ -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(
+ /]*className=["']text-right["'][^>]*>操作<\/TableHead>/s,
+ );
+ expect(source, relativePath).toMatch(
+ /]*>/,
+ );
+ }
+ });
+});
diff --git a/web/src/routes/_app/audit.tsx b/web/src/routes/_app/audit.tsx
deleted file mode 100644
index 9c1d87d..0000000
--- a/web/src/routes/_app/audit.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import { createFileRoute } from '@tanstack/react-router'
-import { AuditPage } from '@/features/operations/AuditPage'
-
-export const Route = createFileRoute('/_app/audit')({ component: AuditPage })
diff --git a/web/src/shared/api/dataProvider.js b/web/src/shared/api/dataProvider.js
index 89f45e8..1a8b0d1 100644
--- a/web/src/shared/api/dataProvider.js
+++ b/web/src/shared/api/dataProvider.js
@@ -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",
diff --git a/web/src/shared/api/dataProvider.test.js b/web/src/shared/api/dataProvider.test.js
index 654a489..d6fcef5 100644
--- a/web/src/shared/api/dataProvider.test.js
+++ b/web/src/shared/api/dataProvider.test.js
@@ -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()
diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js
index 4751769..8de495e 100644
--- a/web/tests/responsive.e2e.js
+++ b/web/tests/responsive.e2e.js
@@ -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')
})