+
+ {mobileOpen ? (
+
+
setMobileOpen(false)} />
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/LoginPage.jsx b/web/src/LoginPage.jsx
new file mode 100644
index 0000000..95e6c7f
--- /dev/null
+++ b/web/src/LoginPage.jsx
@@ -0,0 +1,50 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router'
+import { Alert, Button, Field, Input } from './lib/ui.jsx'
+
+// 开发阶段:本地保存 Basic 凭证,仅在请求层自动附带。开发代理(vite)不注入 Authorization。
+export function LoginPage() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const navigate = useNavigate()
+
+ async function submit(event) {
+ event.preventDefault()
+ if (!username.trim() || !password) return
+ setBusy(true); setError('')
+ try {
+ const response = await fetch('/api/gateways', { headers: { Authorization: `Basic ${btoa(`${username}:${password}`)}` } })
+ if (response.status === 401) throw new Error('用户名或密码不正确')
+ if (!response.ok) throw new Error(`认证服务不可用(${response.status})`)
+ localStorage.setItem('creatorhub.auth', `${username}:${password}`)
+ navigate('/accounts')
+ } catch (reason) {
+ setError(reason.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+
+
CreatorHub
+
+ {error ?
{error} : null}
+
+
+
+ )
+}
diff --git a/web/src/NetworkExitList.jsx b/web/src/NetworkExitList.jsx
deleted file mode 100644
index f0eb2d6..0000000
--- a/web/src/NetworkExitList.jsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import { useEffect, useMemo, useState } from 'react'
-import { useDataProvider, useGetList } from 'ra-core'
-import {
- Alert,
- Box,
- Button,
- CircularProgress,
- MenuItem,
- Paper,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- TextField,
- Typography,
-} from '@mui/material'
-import BlockOutlined from '@mui/icons-material/BlockOutlined'
-import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined'
-import ErrorOutlined from '@mui/icons-material/ErrorOutlined'
-import PublicOutlined from '@mui/icons-material/PublicOutlined'
-import RefreshOutlined from '@mui/icons-material/RefreshOutlined'
-
-const emptyExit = { protocol: 'socks5', host: '', port: '', credential_reference_id: '', expected_public_ip: '', expected_region: '' }
-const healthLabels = { unchecked: '未检测', healthy: '健康', unhealthy: '不健康', disabled: '已停用' }
-const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
-
-function actionError(error, conflictMessage) {
- if (error?.status === 409) return `冲突(409):${conflictMessage}`
- if (error?.status === 503) return `环境不可用(503):${error.message}`
- return error?.message || '操作失败'
-}
-
-function environmentError(error) {
- return `环境不可用${error?.status ? `(${error.status})` : ''}:无法读取账号绑定,状态暂时未知。`
-}
-
-function ExitHealth({ exit }) {
- const known = Object.hasOwn(healthLabels, exit.health_status)
- const label = known ? healthLabels[exit.health_status] : '未知状态'
- const Icon = exit.health_status === 'healthy' ? CheckCircleOutlined : exit.health_status === 'disabled' ? BlockOutlined : ErrorOutlined
- const color = exit.health_status === 'healthy' ? 'success.main' : exit.health_status === 'unchecked' ? 'warning.main' : 'error.main'
- return
{label}{known ? null : {exit.health_status}}
-}
-
-function ExitActions({ exit, busy, onAction }) {
- return
} disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'check')}>检测} disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'disable')}>停用
-}
-
-function ExitForm({ busy, onSubmit }) {
- const [form, setForm] = useState(emptyExit)
- const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
- const port = Number(form.port)
- const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535
-
- async function submit(event) {
- event.preventDefault()
- if (!valid) return
- const created = await onSubmit({
- protocol: form.protocol,
- host: form.host.trim(),
- port,
- credential_reference: { id: form.credential_reference_id.trim() },
- expected_public_ip: form.expected_public_ip.trim(),
- expected_region: form.expected_region.trim(),
- })
- if (created) setForm(emptyExit)
- }
-
- return (
-
- update('protocol', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口协议' } }}>{['http', 'https', 'socks4', 'socks5'].map(protocol => )}
- update('host', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口主机', maxLength: 253 } }} />
- update('port', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口端口', min: 1, max: 65535 } }} />
- update('credential_reference_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口凭据引用 ID', maxLength: 128 } }} helperText="只填写已保存的引用,不填写认证秘密" />
- update('expected_public_ip', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期公网 IP' } }} />
- update('expected_region', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期地区', maxLength: 64 } }} />
-
-
- )
-}
-
-function ExitTable({ exits, bindings, bindingsError, busy, onAction }) {
- return
出口健康观测绑定账号操作{exits.map(exit => {exit.protocol}://{exit.host}:{exit.port}{exit.id} · 凭据 {exit.credential_reference?.id || '无'}{exit.observed_public_ip || '尚无观测'}{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')})}
-}
-
-function ExitCards({ exits, bindings, bindingsError, busy, onAction }) {
- return
{exits.map(exit => {exit.protocol}://{exit.host}:{exit.port}{exit.id} · 凭据 {exit.credential_reference?.id || '无'}观测公网 IP:{exit.observed_public_ip || '尚无观测'}最近检测:{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}绑定账号:{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')})}
-}
-
-export function NetworkExitList() {
- const dataProvider = useDataProvider()
- const [busy, setBusy] = useState('')
- const [message, setMessage] = useState(null)
- const [filter, setFilter] = useState('')
- const { data: exits = [], error, isPending, refetch } = useGetList('network-exits')
- const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false })
- const bindings = useMemo(() => {
- const result = new Map()
- for (const browser of browsers) {
- if (!browser.network_exit_id || !browser.account_id) continue
- result.set(browser.network_exit_id, [...(result.get(browser.network_exit_id) || []), browser.account_id])
- }
- return result
- }, [browsers])
- const needle = filter.trim().toLowerCase()
- const visible = needle ? exits.filter(exit => `${exit.id} ${exit.protocol} ${exit.host} ${exit.observed_public_ip || ''}`.toLowerCase().includes(needle)) : exits
-
- useEffect(() => { document.title = 'CreatorHub · 网络出口' }, [])
-
- async function createExit(data) {
- setBusy('create'); setMessage(null)
- try {
- await dataProvider.create('network-exits', { data })
- await refetch()
- setMessage({ severity: 'success', text: '网络出口已创建;检测通过后才可用于账号绑定。' })
- return true
- } catch (reason) {
- setMessage({ severity: 'error', text: actionError(reason, '出口或凭据引用与现有资源冲突;表单内容已保留。') })
- return false
- } finally { setBusy('') }
- }
-
- async function act(exit, action) {
- if (action === 'disable' && !window.confirm(`停用网络出口 ${exit.id}?绑定账号将暂停,排队任务进入 hold。`)) return
- setBusy(exit.id); setMessage(null)
- try {
- await dataProvider.networkExitAction(exit.id, action)
- await Promise.all([refetch(), refetchBrowsers()])
- setMessage({ severity: 'success', text: action === 'check' ? '出口检测完成。' : '出口已停用;绑定账号不会自动恢复。' })
- } catch (reason) {
- setMessage({ severity: 'error', text: actionError(reason, action === 'check' ? '已停用的出口不能检测。' : '出口当前不能停用。') })
- } finally { setBusy('') }
- }
-
- return (
- <>
-
网络出口维护固定出口、健康观测与账号绑定
-
- {message ?
{message.text} : null}
- {error ?
{error.message} : null}
- {browsersError ?
refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null}
- {exits.length > 0 ?
setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null}
- {isPending ? : null}
- {!isPending && exits.length === 0 ? 暂无网络出口。创建并检测健康后,才能绑定运行环境。 : null}
- {!isPending && exits.length > 0 && visible.length === 0 ? 没有符合筛选条件的网络出口 : null}
- {!isPending && visible.length > 0 ? <>> : null}
- >
- )
-}
diff --git a/web/src/NetworkExitsPage.jsx b/web/src/NetworkExitsPage.jsx
new file mode 100644
index 0000000..a380dc3
--- /dev/null
+++ b/web/src/NetworkExitsPage.jsx
@@ -0,0 +1,218 @@
+import { useMemo, useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router'
+import { useDataProvider, useList, useOne } from '@refinedev/core'
+import {
+ Alert, Button, Card, CardContent, ConfirmDialog, DetailList, Field, Input, Modal, PageHeader,
+ PageState, Select, StatusPill, conflictMessage,
+} from './lib/ui.jsx'
+import { useTitle } from './lib/hooks.js'
+
+const createInitial = { protocol: 'socks5', host: '', port: '', credential_reference_id: '', expected_public_ip: '', expected_region: '' }
+const healthText = { unchecked: '未检测', healthy: '健康', unhealthy: '不健康', disabled: '已停用' }
+const protocolOptions = ['http', 'https', 'socks4', 'socks5'].map(value => ({ value, label: value }))
+
+function ExitHealthPill({ exit }) {
+ const known = Object.hasOwn(healthText, exit.health_status)
+ const tone = exit.health_status === 'healthy' ? 'success' : exit.health_status === 'disabled' ? 'neutral' : exit.health_status === 'unchecked' ? 'warning' : 'danger'
+ return
+}
+
+function ExitActions({ exit, busy, onAction }) {
+ const disabled = busy || exit.health_status === 'disabled'
+ return (
+
+
+
+
+ )
+}
+
+function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
+ const [form, setForm] = useState(createInitial)
+ const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
+ const port = Number(form.port)
+ const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535
+
+ async function submit(event) {
+ event.preventDefault()
+ if (!valid) return
+ const created = await onSubmit({
+ protocol: form.protocol, host: form.host.trim(), port,
+ credential_reference: { id: form.credential_reference_id.trim() },
+ expected_public_ip: form.expected_public_ip.trim(), expected_region: form.expected_region.trim(),
+ })
+ if (created) setForm(createInitial)
+ }
+
+ return (
+
+
+
+ >}>
+
+
+ )
+}
+
+function ExitCard({ exit, boundAccounts, bindingsError, busy, onAction }) {
+ return (
+
+
+
+
+
{exit.protocol}://{exit.host}:{exit.port}
+
{exit.id} · 凭据 {exit.credential_reference?.id || '无'}
+
+
+
+ 观测公网 IP:{exit.observed_public_ip || '尚无观测'}
+ 最近检测:{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}
+
+ 绑定账号:
+ {bindingsError ? '状态未知' : (
+ {boundAccounts?.length ? boundAccounts.join('、') : '未绑定'}
+ )}
+
+
+
+
+ )
+}
+
+export function NetworkExitList() {
+ const dataProvider = useDataProvider()('default')
+ const [createOpen, setCreateOpen] = useState(false)
+ const [busy, setBusy] = useState('')
+ const [createError, setCreateError] = useState(null)
+ const [notice, setNotice] = useState(null)
+ const [disabling, setDisabling] = useState(null)
+ const { result, query } = useList({ resource: 'network-exits' })
+ const error = query.error
+ const isPending = query.isPending
+ const exits = result.data ?? []
+ const { result: bindingResult, query: browsersQuery } = useList({ resource: 'browsers', queryOptions: { retry: false } })
+ const browsers = bindingResult.data ?? []
+ const bindingsError = browsersQuery.error
+ const refetchBrowsers = browsersQuery.refetch
+ const accountsByExit = useMemo(() => {
+ const map = new Map
+ for (const runtime of browsers) {
+ if (!runtime.network_exit_id || !runtime.account_id) continue
+ map.set(runtime.network_exit_id, [...(map.get(runtime.network_exit_id) || []), runtime.account_id])
+ }
+ return map
+ }, [browsers])
+ useTitle('CreatorHub · 网络出口')
+
+ async function createExit(data) {
+ setBusy('create')
+ setCreateError(null)
+ try {
+ await dataProvider.create({ resource: 'network-exits', variables: data })
+ await query.refetch()
+ setNotice({ variant: 'success', text: '网络出口已创建;检测通过后才可用于账号绑定。' })
+ return true
+ } catch (reason) {
+ setCreateError(reason)
+ return false
+ } finally {
+ setBusy('')
+ }
+ }
+
+ async function runAction(exit, action) {
+ setBusy(exit.id)
+ setNotice(null)
+ try {
+ await dataProvider.networkExitAction(exit.id, action)
+ await Promise.all([query.refetch(), refetchBrowsers()])
+ setNotice({ variant: 'success', text: action === 'check' ? '出口检测完成。' : '出口已停用;绑定账号不会自动恢复。' })
+ } catch (reason) {
+ setNotice({ variant: 'destructive', text: conflictMessage(reason, action === 'check' ? '已停用的出口不能检测。' : '出口当前不能停用。') })
+ } finally {
+ setBusy('')
+ }
+ }
+
+ return (
+
+
+
+
+ {notice ?
{notice.text} : null}
+ {error ?
{error.message} : null}
+ {bindingsError ? (
+
+ 环境不可用{bindingsError.status ? `(${bindingsError.status})` : ''}:无法读取账号绑定,状态暂时未知。
+
+
+ ) : null}
+
+
+ {exits.map(exit => (
+ (action === 'disable' ? setDisabling(exit) : runAction(exit, action))} />
+ ))}
+
+
+
{ setCreateOpen(false); setCreateError(null) }} onSubmit={createExit} busy={busy === 'create'} error={createError} />
+ setDisabling(null)} onConfirm={() => { const exit = disabling; setDisabling(null); runAction(exit, 'disable') }}
+ title="停用网络出口" confirmLabel="确认停用" busy={busy === disabling?.id}
+ body={`停用网络出口 ${disabling?.id}?绑定账号将暂停,排队任务进入 hold。`} />
+
+ )
+}
+
+export function NetworkExitDetail() {
+ const { id } = useParams()
+ const { result, error, isPending } = useOne({ resource: 'network-exits', id, queryOptions: { retry: false } })
+ const exit = result
+ useTitle('CreatorHub · 出口详情')
+ if (isPending) return
+ if (error || !exit) return {error?.message || '网络出口不存在'}
+ return (
+
+
{exit.protocol}://{exit.host}:{exit.port}}>
+
+
+
+
+
+ {exit.id}],
+ ['健康 / 版本', {healthText[exit.health_status] ?? exit.health_status} · {exit.version}],
+ ['凭据引用', {exit.credential_reference?.id || '无'}],
+ ['观测公网 IP', {exit.observed_public_ip || '尚无观测'}],
+ ['最近检测', exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'],
+ ]} />
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/NetworkExitList.test.jsx b/web/src/NetworkExitsPage.test.jsx
similarity index 61%
rename from web/src/NetworkExitList.test.jsx
rename to web/src/NetworkExitsPage.test.jsx
index ec96680..a1b477f 100644
--- a/web/src/NetworkExitList.test.jsx
+++ b/web/src/NetworkExitsPage.test.jsx
@@ -1,15 +1,19 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { CoreAdminContext, HttpError } from 'ra-core'
-import { NetworkExitList } from './NetworkExitList'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { Refine } from '@refinedev/core'
+import { MemoryRouter } from 'react-router'
+import { NetworkExitList } from './NetworkExitsPage'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
+const httpError = (message, status, body) => Object.assign(new Error(message), { status, body })
+
const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', observed_public_ip: '203.0.113.1', credential_reference: { id: 'credential-a', provider: 'os_keyring' } }
function provider(exits = [], overrides = {}) {
return {
- getList: vi.fn(resource => Promise.resolve(resource === 'network-exits' ? { data: exits, total: exits.length } : { data: [], total: 0 })),
+ getList: vi.fn(({ resource }) => Promise.resolve(resource === 'network-exits' ? { data: exits, total: exits.length } : { data: [], total: 0 })),
create: vi.fn().mockResolvedValue({ data: networkExit }),
networkExitAction: vi.fn().mockResolvedValue(networkExit),
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
@@ -17,17 +21,25 @@ function provider(exits = [], overrides = {}) {
}
}
+const renderExits = dataProvider => render(
+
+
+
+
+ ,
+)
+
describe('NetworkExitList', () => {
it('shows the ordered empty-state instruction', async () => {
- render()
+ renderExits(provider())
expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy()
})
it('shows only credential references and runs an explicit health check', async () => {
const dataProvider = provider([networkExit])
- render()
+ renderExits(dataProvider)
- expect((await screen.findAllByText(/凭据 credential-a/)).length).toBeGreaterThan(0)
+ expect((await screen.findAllByText(/credential-a/)).length).toBeGreaterThan(0)
expect(screen.queryByText(/password|token|raw-value/i)).toBeNull()
fireEvent.click(screen.getAllByRole('button', { name: '检测' })[0])
await waitFor(() => expect(dataProvider.networkExitAction).toHaveBeenCalledWith('exit-a', 'check'))
@@ -35,18 +47,18 @@ describe('NetworkExitList', () => {
it('shows unknown bindings and retries when browsers return 502', async () => {
const dataProvider = provider([networkExit], {
- getList: vi.fn(resource => resource === 'browsers'
- ? Promise.reject(new HttpError('bad gateway', 502))
+ getList: vi.fn(({ resource }) => resource === 'browsers'
+ ? Promise.reject(httpError('bad gateway', 502))
: Promise.resolve({ data: [networkExit], total: 1 })),
})
- render()
+ renderExits(dataProvider)
- expect(await screen.findByText(/环境不可用(502)/)).toBeTruthy()
- expect((await screen.findAllByText(/绑定账号:状态未知|^状态未知$/)).length).toBeGreaterThan(0)
+ expect(await screen.findByText(/环境不可用/)).toBeTruthy()
+ expect((await screen.findAllByText(/绑定账号:状态未知|状态未知/)).length).toBeGreaterThan(0)
expect(screen.queryByText(/绑定账号:未绑定/)).toBeNull()
expect(screen.queryByText(/^未绑定$/)).toBeNull()
expect(screen.getAllByRole('button', { name: '检测' })[0].disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '重试环境状态' }))
- await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([resource]) => resource === 'browsers').length).toBeGreaterThan(1))
+ await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([call]) => call.resource === 'browsers').length).toBeGreaterThan(1))
})
})
diff --git a/web/src/TaskCenter.jsx b/web/src/TaskCenter.jsx
deleted file mode 100644
index f078569..0000000
--- a/web/src/TaskCenter.jsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useEffect, useState } from 'react'
-import { useDataProvider, useGetList, useGetOne } from 'ra-core'
-import { Link as RouterLink, useParams } from 'react-router-dom'
-import {
- Alert,
- Box,
- Button,
- CircularProgress,
- MenuItem,
- Paper,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- TextField,
- Typography,
-} from '@mui/material'
-import AssignmentTurnedInOutlined from '@mui/icons-material/AssignmentTurnedInOutlined'
-
-const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
-
-export const taskLabels = {
- queued: '已排队',
- executing: '执行中',
- succeeded: '已成功',
- failed: '失败',
- needs_confirmation: '需要人工确认',
- policy_hold: '策略暂停',
- cancelled: '已取消',
-}
-
-export const reasonLabels = {
- account_paused: '账号已暂停',
- account_revoked: '账号授权已撤销',
- binding_missing: '运行环境绑定缺失',
- environment_missing: '原运行环境不可用',
- exit_missing: '固定出口缺失',
- exit_unhealthy: '固定出口失败或发生漂移',
- runtime_stop_pending: '停止结果未知,等待人工核验',
- runtime_missing: '原运行实例不可用',
- runtime_lease_expired: '运行实例租约已过期',
- execution_lease_expired: '执行租约过期,结果未知',
- task_result_uncertain: '执行结果不确定',
- task_policy_hold: '执行器请求策略暂停',
- account_version_changed: '账号版本已变化',
- draft_version_changed: '草稿版本已变化',
- confirmation_missing: '确认快照缺失',
- confirmation_version_changed: '确认版本已变化',
- binding_version_changed: '环境或出口版本已变化',
- legacy_confirmation_required: '历史任务结果未知,等待人工核验',
-}
-
-const stateText = state => taskLabels[state] || `未知状态:${state}`
-const reasonText = reason => reason ? (reasonLabels[reason] || `未知停止原因:${reason}`) : '无'
-
-function TaskSummary({ task }) {
- return (
- <>
- {task.id}
- {stateText(task.state)}
- {reasonText(task.hold_reason)}
- >
- )
-}
-
-export function TaskList() {
- const [accountID, setAccountID] = useState('')
- const [state, setState] = useState('')
- const { data: tasks = [], error, isPending } = useGetList('tasks', { filter: { account_id: accountID, state } }, { retry: false })
-
- useEffect(() => { document.title = 'CreatorHub · 任务中心' }, [])
-
- return (
-
-
- 任务中心
- 逐条处理暂停、版本变化与未知执行结果;不会自动重试或更换出口
-
-
- setAccountID(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '按账号筛选' } }} />
- setState(event.target.value)} sx={{ minWidth: 220 }} slotProps={{ htmlInput: { 'aria-label': '按状态筛选' } }}>
-
- {Object.entries(taskLabels).map(([value, label]) => )}
-
-
- {error ? {error.message} : null}
- {isPending ? : null}
- {!isPending && tasks.length === 0 ? {accountID || state ? '没有符合筛选条件的任务' : '尚无任务'}{accountID || state ? : null} : null}
- {tasks.length > 0 ? 任务账号确认更新时间{tasks.map(task => {task.account_id}{task.confirmation_id || '未确认'}{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')})}
: null}
- {tasks.length > 0 ? {tasks.map(task => 账号:{task.account_id}确认:{task.confirmation_id || '未确认'}{new Date(task.updated_at || task.created_at).toLocaleString('zh-CN')})} : null}
-
- )
-}
-
-function actionError(error) {
- const reason = error?.body?.reason_code
- if (error?.status === 409) return `当前版本仍不允许恢复(409):${reasonText(reason)}`
- if (error?.status === 503) return `原资源尚未恢复(503):${reasonText(reason)}`
- return error?.message || '操作失败'
-}
-
-export function TaskDetail() {
- const { id } = useParams()
- const dataProvider = useDataProvider()
- const [verification, setVerification] = useState('not_executed')
- const [busy, setBusy] = useState(false)
- const [message, setMessage] = useState(null)
- const { data: task, error, isPending, refetch } = useGetOne('tasks', { id }, { retry: false })
-
- useEffect(() => { document.title = 'CreatorHub · 任务详情' }, [])
-
- if (isPending) return
- if (error || !task) return {error?.message || '任务不存在'}
-
- async function act(action, data) {
- setBusy(true)
- setMessage(null)
- try {
- await dataProvider.taskAction(task.id, action, data)
- setMessage({ severity: 'success', text: action === 'verify' ? '人工核验已记录;现在只能按该结论恢复或结束。' : action === 'resume' ? '任务已恢复排队。' : '任务已按核验结论结束。' })
- await refetch()
- } catch (reason) {
- setMessage({ severity: 'error', text: actionError(reason) })
- } finally {
- setBusy(false)
- }
- }
-
- const confirmation = task.confirmation || {}
- const unknownReason = task.hold_reason && !reasonLabels[task.hold_reason]
- return (
-
- 任务详情{task.id}
- {message ? {message.text} : null}
- {unknownReason ? 后端返回了未知停止原因“{task.hold_reason}”;为避免不安全操作,本页不提供恢复动作。 : null}
-
- 当前状态{stateText(task.state)}停止原因:{reasonText(task.hold_reason)}人工核验:{task.verification_result || '尚未记录'}
- 确认快照确认:{confirmation.id || '未记录'} · v{confirmation.version || '—'}账号版本 {confirmation.account_version || '—'} · 草稿版本 {confirmation.draft_version || '—'}环境:{confirmation.browser_env_alias || '未记录'} · 出口:{confirmation.network_exit_id || '未记录'} · 绑定版本:{confirmation.binding_version || '未记录'}
- 执行关联环境:{task.browser_env_alias || '未记录'}固定出口:{task.network_exit_id || '未记录'}运行实例:{task.runtime_instance_id || '未记录'} · 绑定版本:{task.binding_version || '未记录'}{task.browser_env_alias ? : null}{task.network_exit_id ? : null}
- 唯一安全动作{task.readiness_reason ? 当前仍被阻断:{reasonText(task.readiness_reason)}。修复原资源并刷新后,才会开放恢复。 : null}{task.allowed_action === 'verify' ? <>先在原环境和原出口核验真实结果。选择“未执行”不会立即重试,仍需下一步显式恢复。 setVerification(event.target.value)} slotProps={{ htmlInput: { 'aria-label': '人工核验结论' } }}>> : null}{task.allowed_action === 'resume' ? : null}{task.allowed_action === 'finish' ? : null}{task.allowed_action === 'reconfirm' ? <>版本已变化,旧确认不可继续使用。> : null}{!task.allowed_action ? 当前没有可安全执行的动作。 : null}
-
- 执行尝试{task.attempts?.length ? {task.attempts.map(attempt => {attempt.id}{attempt.outcome || '执行中'} · {new Date(attempt.started_at).toLocaleString('zh-CN')}脱敏证据:{attempt.evidence?.mock_outcome || '无可公开证据'})} : 尚未产生执行尝试。}
-
- )
-}
diff --git a/web/src/TasksPage.jsx b/web/src/TasksPage.jsx
new file mode 100644
index 0000000..6e55a8c
--- /dev/null
+++ b/web/src/TasksPage.jsx
@@ -0,0 +1,212 @@
+import { useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router'
+import { useDataProvider, useList, useOne } from '@refinedev/core'
+import {
+ Alert, Button, Card, CardContent, DetailList, Field, PageHeader, PageState, Select, StatusPill,
+} from './lib/ui.jsx'
+import { useTitle } from './lib/hooks.js'
+
+const stateTone = { queued: 'neutral', executing: 'info', succeeded: 'success', failed: 'danger', needs_confirmation: 'warning', policy_hold: 'warning', cancelled: 'neutral' }
+const stateText = { queued: '已排队', executing: '执行中', succeeded: '已成功', failed: '失败', needs_confirmation: '需要人工确认', policy_hold: '策略暂停', cancelled: '已取消' }
+const holdText = {
+ account_paused: '账号已暂停', account_revoked: '账号授权已撤销', binding_missing: '运行环境绑定缺失',
+ environment_missing: '原运行环境不可用', exit_missing: '固定出口缺失', exit_unhealthy: '固定出口失败或发生漂移',
+ runtime_stop_pending: '停止结果未知,等待人工核验', runtime_missing: '原运行实例不可用', runtime_lease_expired: '运行实例租约已过期',
+ execution_lease_expired: '执行租约过期,结果未知', task_result_uncertain: '执行结果不确定', task_policy_hold: '执行器请求策略暂停',
+ account_version_changed: '账号版本已变化', draft_version_changed: '草稿版本已变化', confirmation_missing: '确认快照缺失',
+ confirmation_version_changed: '确认版本已变化', binding_version_changed: '环境或出口版本已变化', legacy_confirmation_required: '历史任务结果未知,等待人工核验',
+}
+const stateLabel = state => stateText[state] || `未知状态:${state}`
+const holdLabel = reason => reason ? holdText[reason] || `未知停止原因:${reason}` : '无'
+
+function actionErrorText(error) {
+ const reason = error?.body?.reason_code
+ if (error?.status === 409) return `当前版本仍不允许恢复(409):${holdLabel(reason)}`
+ if (error?.status === 503) return `原资源尚未恢复(503):${holdLabel(reason)}`
+ 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('')
+ const { result, query } = useList({
+ resource: 'tasks',
+ filters: [
+ { field: 'account_id', value: accountFilter },
+ { field: 'state', value: stateFilter },
+ ],
+ })
+ const error = query.error
+ const isPending = query.isPending
+ const tasks = result.data ?? []
+ useTitle('CreatorHub · 任务中心')
+
+ return (
+
+
+
+
+ setAccountFilter(event.target.value)} placeholder="账号 ID" className="h-9 w-full rounded-md border border-[#c4ccd9] bg-white px-3 text-sm focus:border-primary focus:outline-none" />
+
+
+
+
+ {error ?
{error.message} : null}
+
+
+ {tasks.map(task => )}
+
+
+
+ )
+}
+
+export function TaskDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const dataProvider = useDataProvider()('default')
+ const [verification, setVerification] = useState('not_executed')
+ const [busy, setBusy] = useState(false)
+ const [notice, setNotice] = useState(null)
+ const { result, error, isPending, query } = useOne({ resource: 'tasks', id, queryOptions: { retry: false } })
+ const task = result
+ useTitle('CreatorHub · 任务详情')
+
+ if (isPending) return
+ if (error || !task) return {error?.message || '任务不存在'}
+
+ async function runAction(action, data) {
+ setBusy(true)
+ setNotice(null)
+ try {
+ await dataProvider.taskAction(task.id, action, data)
+ setNotice({ variant: 'success', text: action === 'verify' ? '人工核验已记录;现在只能按该结论恢复或结束。' : action === 'resume' ? '任务已恢复排队。' : '任务已按核验结论结束。' })
+ await query.refetch()
+ } catch (reason) {
+ setNotice({ variant: 'destructive', text: actionErrorText(reason) })
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const confirmation = task.confirmation || {}
+ const unknownHold = task.hold_reason && !holdText[task.hold_reason]
+
+ return (
+
+
{task.id}}>
+
+
+ {notice ?
{notice.text} : null}
+ {unknownHold ?
后端返回了未知停止原因“{task.hold_reason}”;为避免不安全操作,本页不提供恢复动作。 : null}
+
+
+
+ 当前状态
+
+ {holdLabel(task.hold_reason)}],
+ ['人工核验', task.verification_result || '尚未记录'],
+ ]} />
+
+
+
+
+
+
+
+ 确认快照
+ {confirmation.id || '未记录'} · v{confirmation.version || '—'}],
+ ['版本', <>账号版本 {confirmation.account_version || '—'} · 草稿版本 {confirmation.draft_version || '—'}>],
+ ['环境与出口', {confirmation.browser_env_alias || '未记录'} · {confirmation.network_exit_id || '未记录'} · 绑定版本:{confirmation.binding_version || '未记录'}],
+ ]} />
+
+
+
+
+
+
+
+
+ 执行关联
+ {task.browser_env_alias || '未记录'}],
+ ['固定出口', {task.network_exit_id || '未记录'}],
+ ['运行实例 / 绑定版本', {task.runtime_instance_id || '未记录'} · {task.binding_version || '未记录'}],
+ ]} />
+
+ {task.browser_env_alias ? : null}
+ {task.network_exit_id ? : null}
+
+
+
+
+
+ 唯一安全动作
+ {task.readiness_reason ? 当前仍被阻断:{holdLabel(task.readiness_reason)}。修复原资源并刷新后,才会开放恢复。 : null}
+ {task.allowed_action === 'verify' ? (
+ <>
+ 先在原环境和原出口核验真实结果。选择“未执行”不会立即重试,仍需下一步显式恢复。
+
+
+
+ >
+ ) : null}
+ {task.allowed_action === 'resume' ? : null}
+ {task.allowed_action === 'finish' ? : null}
+ {task.allowed_action === 'reconfirm' ? (
+ <>
+ 版本已变化,旧确认不可继续使用。
+
+ >
+ ) : null}
+ {task.allowed_action ? null : 当前没有可安全执行的动作。
}
+
+
+
+
+ 执行尝试
+ {task.attempts?.length ? (
+
+ {task.attempts.map(attempt => (
+
+
+ {attempt.id}
+ {attempt.outcome || '执行中'} · {new Date(attempt.started_at).toLocaleString('zh-CN')}
+ 脱敏证据:{attempt.evidence?.mock_outcome || '无可公开证据'}
+
+
+ ))}
+
+ ) : (
+ 尚未产生执行尝试。
+ )}
+
+
+ )
+}
diff --git a/web/src/TaskCenter.test.jsx b/web/src/TasksPage.test.jsx
similarity index 65%
rename from web/src/TaskCenter.test.jsx
rename to web/src/TasksPage.test.jsx
index 5bb04e2..45e0a2a 100644
--- a/web/src/TaskCenter.test.jsx
+++ b/web/src/TasksPage.test.jsx
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { CoreAdminContext } from 'ra-core'
-import { MemoryRouter, Route, Routes } from 'react-router-dom'
-import { TaskDetail } from './TaskCenter'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { Refine } from '@refinedev/core'
+import { MemoryRouter, Route, Routes } from 'react-router'
+import { TaskDetail, TaskList } from './TasksPage'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
@@ -16,14 +17,31 @@ const task = {
function provider(record = task) {
return {
getOne: vi.fn().mockResolvedValue({ data: record }),
+ getList: vi.fn(({ resource }) => Promise.resolve(resource === 'tasks' ? { data: [task], total: 1 } : { data: [], total: 0 })),
taskAction: vi.fn().mockResolvedValue(),
- getList: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
+ getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
}
}
-function renderTask(dataProvider) {
- return render(} />)
-}
+const queryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const renderTask = dataProvider => render(
+
+
+
+ } />
+
+
+ ,
+)
+
+const renderTasks = dataProvider => render(
+
+
+
+
+ ,
+)
describe('TaskDetail', () => {
it('requires a structured manual verification before any resume', async () => {
@@ -60,8 +78,20 @@ describe('TaskDetail', () => {
it('renders a NULL confirmation without exposing recovery or reconfirm actions', async () => {
renderTask(provider({ ...task, confirmation_id: '', confirmation: {}, hold_reason: 'confirmation_missing', allowed_action: '', attempts: [] }))
- expect(await screen.findByText(/确认:未记录/)).toBeTruthy()
+ expect((await screen.findAllByText(/未记录/)).length).toBeGreaterThan(0)
expect(screen.queryByRole('link', { name: '重新核对并确认草稿' })).toBeNull()
expect(screen.queryByRole('button', { name: /恢复排队|记录人工核验|结束任务/ })).toBeNull()
})
})
+
+describe('TaskList', () => {
+ it('filters tasks by account and state through the data provider', async () => {
+ const dataProvider = provider()
+ renderTasks(dataProvider)
+
+ expect((await screen.findAllByText('task-a')).length).toBeGreaterThan(0)
+ fireEvent.change(screen.getByRole('textbox', { name: '按账号筛选' }), { target: { value: 'account-a' } })
+
+ await waitFor(() => expect(dataProvider.getList.mock.calls.some(([call]) => call.filters?.some(filter => filter.field === 'account_id' && filter.value === 'account-a'))).toBe(true))
+ })
+})
diff --git a/web/src/TraceDetails.jsx b/web/src/TraceDetails.jsx
deleted file mode 100644
index f236b53..0000000
--- a/web/src/TraceDetails.jsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useEffect } from 'react'
-import { useGetOne } from 'ra-core'
-import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from '@mui/material'
-import { Link as RouterLink, useParams } from 'react-router-dom'
-
-const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
-
-function DetailState({ pending, error, children }) {
- if (pending) return
- if (error) return {error.message}
- return children
-}
-
-export function AttemptDetail() {
- const { id } = useParams()
- const { data: attempt, error, isPending } = useGetOne('attempts', { id }, { retry: false })
- useEffect(() => { document.title = 'CreatorHub · Attempt 详情' }, [])
- return {attempt ? Attempt 详情{attempt.id}结果:{attempt.outcome || '执行中'}开始:{new Date(attempt.started_at).toLocaleString('zh-CN')}环境:{attempt.browser_env_alias || '未记录'} · 出口:{attempt.network_exit_id || '未记录'} · 绑定版本:{attempt.binding_version || '未记录'}{attempt.browser_env_alias ? : null}{attempt.network_exit_id ? : null} : null}
-}
-
-export function BrowserDetail() {
- const { id } = useParams()
- const { data: environment, error, isPending } = useGetOne('browsers', { id }, { retry: false })
- useEffect(() => { document.title = 'CreatorHub · 环境详情' }, [])
- return {environment ? 运行环境详情{environment.name}({environment.alias})账号:{environment.account_id} · 镜像:{environment.image_version}固定出口:{environment.network_exit?.id || '未绑定'} · 健康:{environment.network_exit?.health_status || '未知'}绑定版本:{environment.binding_version} · 运行实例:{environment.runtime_instance_id || '无'}{environment.network_exit?.id ? : null} : null}
-}
-
-export function NetworkExitDetail() {
- const { id } = useParams()
- const { data: exit, error, isPending } = useGetOne('network-exits', { id }, { retry: false })
- useEffect(() => { document.title = 'CreatorHub · 出口详情' }, [])
- return {exit ? 网络出口详情{exit.id}{exit.protocol}://{exit.host}:{exit.port}健康:{exit.health_status} · 版本:{exit.version}凭据引用:{exit.credential_reference?.id || '无'} : null}
-}
diff --git a/web/src/TracePages.jsx b/web/src/TracePages.jsx
new file mode 100644
index 0000000..28a9c8d
--- /dev/null
+++ b/web/src/TracePages.jsx
@@ -0,0 +1,41 @@
+import { Link, useParams } from 'react-router'
+import { useOne } from '@refinedev/core'
+import { Alert, Button, Card, CardContent, DetailList, PageHeader, PageState } from './lib/ui.jsx'
+import { useTitle } from './lib/hooks.js'
+
+function PendingOrError({ pending, error, children }) {
+ if (pending) return
+ if (error) return {error.message}
+ return children
+}
+
+export function AttemptDetail() {
+ const { id } = useParams()
+ const { result, error, isPending } = useOne({ resource: 'attempts', id, queryOptions: { retry: false } })
+ const attempt = result
+ useTitle('CreatorHub · Attempt 详情')
+ return (
+
+ {attempt ? (
+
+
{attempt.id}} />
+
+
+ {attempt.browser_env_alias || '未记录'} · {attempt.network_exit_id || '未记录'} · {attempt.binding_version || '未记录'}],
+ ]} />
+
+
+
+ {attempt.browser_env_alias ? : null}
+ {attempt.network_exit_id ? : null}
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/web/src/dataProvider.js b/web/src/dataProvider.js
index fb1d6e7..44b9a41 100644
--- a/web/src/dataProvider.js
+++ b/web/src/dataProvider.js
@@ -1,10 +1,24 @@
-import { HttpError } from 'ra-core'
+// 401 = 未认证:抹去凭证并引导到登录页。开发代理下 401 由后端直接返回。
+const unauthorized = () => {
+ localStorage.removeItem('creatorhub.auth')
+ if (!location.hash.startsWith('#/login')) location.hash = '#/login'
+ return Object.assign(new Error('认证已过期,请重新登录'), { status: 401 })
+}
async function request(path = '', options) {
- const response = await fetch(`/api${path}`, options)
+ const auth = localStorage.getItem('creatorhub.auth')
+ const headers = { ...options?.headers }
+ if (auth) headers.Authorization = `Basic ${btoa(auth)}`
+ let response
+ try {
+ response = await fetch(`/api${path}`, { ...options, headers })
+ } catch (networkError) {
+ throw Object.assign(new Error(`网络请求失败:${networkError.message}`), { status: 0 })
+ }
+ if (response.status === 401) throw unauthorized()
if (!response.ok) {
const body = await response.json().catch(() => ({}))
- throw new HttpError(body.error || `请求失败 (${response.status})`, response.status, body)
+ throw Object.assign(new Error(body.error || `请求失败 (${response.status})`), { status: response.status, body })
}
return response.status === 204 ? null : response.json()
}
@@ -30,48 +44,54 @@ const resourcePaths = {
'network-exits': '/network-exits',
}
+const withID = (record, fallback) => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name ?? fallback })
+
export const dataProvider = {
- async getList(resource, params = {}) {
+ // Refine v5 契约:所有方法收单个参数对象(getList 收 {resource, pagination, filters, meta})。
+ async getList({ resource, pagination = {}, filters = [], meta = {} }) {
const path = resourcePaths[resource]
if (!path) return unsupported(resource, 'getList')
- const filterKeys = {
- 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'],
- }[resource] || []
- const query = new URLSearchParams(filterKeys.flatMap(key => params.filter?.[key] ? [[key, params.filter[key]]] : []))
+ // filters 为 [{field, value, operator}];meta 是 Refine 透传的查询上下文。
+ const valueOf = field => {
+ const direct = meta?.[field]
+ if (direct !== undefined) return direct
+ const entry = filters?.find(item => item.field === field)
+ return entry?.value !== undefined && entry.value !== null && entry.value !== '' ? entry.value : undefined
+ }
+ const query = new URLSearchParams(filterKeys(resource).flatMap(field => valueOf(field) !== undefined ? [[field, valueOf(field)]] : []))
if (resource === 'audit') {
- query.set('page', params.pagination?.page || 1)
- query.set('page_size', params.pagination?.perPage || 25)
+ query.set('page', pagination.currentPage || pagination.page || 1)
+ query.set('page_size', pagination.pageSize || pagination.perPage || 25)
}
const records = await request(`${path}${query.size ? `?${query}` : ''}`)
const data = Array.isArray(records) ? records : records.data
- return { data: data.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.total ?? data.length }
+ return { data: data.map((record, index) => withID(record, index)), total: records.total ?? data.length }
},
- async create(resource, { data }) {
- const path = resourcePaths[resource]
- if (!path) return unsupported(resource, 'create')
- const created = await request(path, jsonOptions('POST', data))
- return { data: { ...data, ...created, id: created?.id ?? created?.alias ?? created?.version ?? created?.name ?? data.id ?? data.alias } }
- },
- async update(resource, { id, data }) {
- const path = resourcePaths[resource]
- if (!path || resource === 'browsers') return unsupported(resource, 'update')
- // browser-images 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
- const { version: _ignored, ...rest } = data ?? {}
- await request(`${path}/${encodeURIComponent(id)}`, jsonOptions('PUT', rest))
- return { data: { ...rest, id } }
- },
- async delete(resource, { id }) {
- const path = resourcePaths[resource]
- if (!path) return unsupported(resource, 'delete')
- await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
- return { data: { id } }
- },
- async getOne(resource, { id }) {
+ async getOne({ resource, id }) {
const path = resourcePaths[resource]
if (!path || !['accounts', 'network-exits', 'browsers', 'drafts', 'confirmations', 'tasks', 'attempts'].includes(resource)) return unsupported(resource, 'getOne')
const record = await request(`${path}/${encodeURIComponent(id)}`)
- return { data: { ...record, id: record.id ?? id } }
+ return { data: withID(record, id) }
+ },
+ async create({ resource, variables }) {
+ const path = resourcePaths[resource]
+ if (!path) return unsupported(resource, 'create')
+ const created = await request(path, jsonOptions('POST', variables))
+ return { data: withID({ ...variables, ...created }, variables.id ?? variables.alias) }
+ },
+ async update({ resource, id, variables }) {
+ const path = resourcePaths[resource]
+ if (!path || resource === 'browsers') return unsupported(resource, 'update')
+ // browser-images 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
+ const { version: _ignored, ...rest } = variables ?? {}
+ await request(`${path}/${encodeURIComponent(id)}`, jsonOptions('PUT', rest))
+ return { data: { ...rest, id } }
+ },
+ async deleteOne({ resource, id }) {
+ const path = resourcePaths[resource]
+ if (!path || resource !== 'browser-images') return unsupported(resource, 'deleteOne')
+ await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
+ return { data: { id } }
},
getMany: resource => unsupported(resource, 'getMany'),
getManyReference: resource => unsupported(resource, 'getManyReference'),
@@ -119,3 +139,10 @@ export const dataProvider = {
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
},
}
+
+function filterKeys(resource) {
+ return {
+ 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'],
+ }[resource] || []
+}
diff --git a/web/src/dataProvider.test.js b/web/src/dataProvider.test.js
index 7a62d4f..a898ced 100644
--- a/web/src/dataProvider.test.js
+++ b/web/src/dataProvider.test.js
@@ -3,16 +3,38 @@ import { dataProvider } from './dataProvider'
afterEach(() => vi.unstubAllGlobals())
+const httpError = (message, status, body) => Object.assign(new Error(message), { status, body })
+
describe('dataProvider', () => {
- it('maps the existing browser list into react-admin records', async () => {
+ it('maps the existing browser list into refine records', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{ name: 'account-a', state: 'running' }]), { status: 200 }))
vi.stubGlobal('fetch', fetch)
- await expect(dataProvider.getList('browsers')).resolves.toEqual({
+ await expect(dataProvider.getList({ resource: 'browsers' })).resolves.toEqual({
data: [{ id: 'account-a', name: 'account-a', state: 'running' }],
total: 1,
})
- expect(fetch).toHaveBeenCalledWith('/api/browsers', undefined)
+ expect(fetch).toHaveBeenCalledWith('/api/browsers', { headers: {} })
+ })
+
+ it('attaches Basic credentials from localStorage', async () => {
+ localStorage.setItem('creatorhub.auth', 'user:pass')
+ const fetch = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
+ vi.stubGlobal('fetch', fetch)
+
+ await dataProvider.getList({ resource: 'browsers' })
+ expect(fetch).toHaveBeenCalledWith('/api/browsers', { headers: { Authorization: 'Basic dXNlcjpwYXNz' } })
+ localStorage.removeItem('creatorhub.auth')
+ })
+
+ it('clears credentials and redirects to login on 401', async () => {
+ localStorage.setItem('creatorhub.auth', 'user:pass')
+ const fetch = vi.fn().mockResolvedValue(new Response('', { status: 401 }))
+ vi.stubGlobal('fetch', fetch)
+
+ await expect(dataProvider.getList({ resource: 'browsers' })).rejects.toMatchObject({ status: 401 })
+ expect(localStorage.getItem('creatorhub.auth')).toBeNull()
+ expect(location.hash).toBe('#/login')
})
it.each([
@@ -22,8 +44,8 @@ describe('dataProvider', () => {
const fetch = vi.fn().mockResolvedValue(new Response('[{"id":"record-1"}]', { status: 200 }))
vi.stubGlobal('fetch', fetch)
- await expect(dataProvider.getList(resource)).resolves.toMatchObject({ data: [{ id: 'record-1' }] })
- expect(fetch).toHaveBeenCalledWith(path, undefined)
+ await expect(dataProvider.getList({ resource })).resolves.toMatchObject({ data: [{ id: 'record-1' }] })
+ expect(fetch).toHaveBeenCalledWith(path, { headers: {} })
})
it.each([
@@ -38,8 +60,8 @@ describe('dataProvider', () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 }))
vi.stubGlobal('fetch', fetch)
- await expect(dataProvider.getOne(resource, { id })).resolves.toEqual({ data: { id } })
- expect(fetch).toHaveBeenCalledWith(path, undefined)
+ await expect(dataProvider.getOne({ resource, id })).resolves.toEqual({ data: { id } })
+ expect(fetch).toHaveBeenCalledWith(path, { headers: {} })
})
it('creates an account without a client-generated technical id', async () => {
@@ -47,7 +69,7 @@ describe('dataProvider', () => {
vi.stubGlobal('fetch', fetch)
const data = { name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: ['主账号'], cookies: 'sessionid=value' }
- await expect(dataProvider.create('accounts', { data })).resolves.toEqual({ data: { ...data, id: 'account-generated' } })
+ await expect(dataProvider.create({ resource: 'accounts', variables: data })).resolves.toEqual({ data: { ...data, id: 'account-generated' } })
expect(fetch).toHaveBeenCalledWith('/api/phase-a/accounts', expect.objectContaining({ method: 'POST' }))
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(data)
})
@@ -56,18 +78,24 @@ describe('dataProvider', () => {
const fetch = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
vi.stubGlobal('fetch', fetch)
- await dataProvider.getList('drafts', { filter: { account_id: 'account-a' } })
- expect(fetch).toHaveBeenCalledWith('/api/phase-a/drafts?account_id=account-a', undefined)
+ await dataProvider.getList({ resource: 'drafts', filters: [{ field: 'account_id', value: 'account-a' }] })
+ expect(fetch).toHaveBeenCalledWith('/api/phase-a/drafts?account_id=account-a', { headers: {} })
})
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('audit', { pagination: { page: 2, perPage: 25 }, filter: { task_id: 'task-a', attempt_id: 'attempt-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a' } })).resolves.toEqual({
+ 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', undefined)
+ 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 () => {
@@ -94,13 +122,21 @@ describe('dataProvider', () => {
vi.stubGlobal('fetch', fetch)
await dataProvider.browserAction('account-a', action)
- expect(fetch).toHaveBeenCalledWith(path, { method })
+ expect(fetch).toHaveBeenCalledWith(path, expect.objectContaining({ method }))
+ })
+
+ it('sends upgrade with the version payload', async () => {
+ const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
+ vi.stubGlobal('fetch', fetch)
+
+ await dataProvider.browserAction('account-a', 'upgrade', { version: '144.0.0.1' })
+ expect(fetch).toHaveBeenCalledWith('/api/browsers/account-a/upgrade', expect.objectContaining({ method: 'POST', body: '{"version":"144.0.0.1"}' }))
})
it('preserves API error messages and status codes', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: '网关不可用' }), { status: 502 })))
- await expect(dataProvider.getList('browsers')).rejects.toMatchObject({ message: '网关不可用', status: 502 })
+ await expect(dataProvider.getList({ resource: 'browsers' })).rejects.toMatchObject({ message: '网关不可用', status: 502 })
})
it.each([409, 503])('preserves structured action errors for status %s', async status => {
@@ -119,7 +155,7 @@ describe('dataProvider', () => {
vi.stubGlobal('fetch', fetch)
await dataProvider.accountAction('account-a', action)
- expect(fetch).toHaveBeenCalledWith(path, { method: 'POST' })
+ expect(fetch).toHaveBeenCalledWith(path, { method: 'POST', headers: {} })
})
it('keeps verification and recovery as separate task actions', async () => {
@@ -131,7 +167,7 @@ describe('dataProvider', () => {
expect(fetch.mock.calls).toEqual([
['/api/phase-a/tasks/task%2Fa/verify', expect.objectContaining({ method: 'POST', body: '{"result":"not_executed"}' })],
- ['/api/phase-a/tasks/task%2Fa/resume', { method: 'POST' }],
+ ['/api/phase-a/tasks/task%2Fa/resume', { method: 'POST', headers: {} }],
])
})
})
diff --git a/web/src/layout.jsx b/web/src/layout.jsx
deleted file mode 100644
index 6fe6fbb..0000000
--- a/web/src/layout.jsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useState } from 'react'
-import { NavLink } from 'react-router-dom'
-import {
- AppBar,
- Box,
- Drawer,
- IconButton,
- ListItemButton,
- ListItemIcon,
- ListItemText,
- Toolbar,
- Typography,
- useMediaQuery,
-} from '@mui/material'
-import ChevronLeft from '@mui/icons-material/ChevronLeft'
-import ChevronRight from '@mui/icons-material/ChevronRight'
-import AccountCircleOutlined from '@mui/icons-material/AccountCircleOutlined'
-import AssignmentTurnedInOutlined from '@mui/icons-material/AssignmentTurnedInOutlined'
-import DnsOutlined from '@mui/icons-material/DnsOutlined'
-import HistoryOutlined from '@mui/icons-material/HistoryOutlined'
-import HubOutlined from '@mui/icons-material/HubOutlined'
-import Inventory2Outlined from '@mui/icons-material/Inventory2Outlined'
-import MenuIcon from '@mui/icons-material/Menu'
-import PublicOutlined from '@mui/icons-material/PublicOutlined'
-import WidgetsOutlined from '@mui/icons-material/WidgetsOutlined'
-import { useTheme } from '@mui/material/styles'
-
-const expandedWidth = 240
-const collapsedWidth = 76
-
-export function CreatorHubMenu({ collapsed, onNavigate }) {
- return (
- <>
-
-
- {collapsed ? null : CreatorHub}
-
-
- {collapsed ? null : 运营}
-
-
- {collapsed ? null : }
-
-
-
- {collapsed ? null : }
-
-
-
- {collapsed ? null : }
-
- {collapsed ? null : 资源}
-
-
- {collapsed ? null : }
-
-
-
- {collapsed ? null : }
-
-
-
- {collapsed ? null : }
-
-
-
- {collapsed ? null : }
-
-
- >
- )
-}
-
-export function CreatorHubLayout({ children }) {
- const theme = useTheme()
- const desktop = useMediaQuery(theme.breakpoints.up('md'))
- const [collapsed, setCollapsed] = useState(false)
- const [mobileOpen, setMobileOpen] = useState(false)
- const width = collapsed ? collapsedWidth : expandedWidth
-
- return (
-
-
-
- desktop ? setCollapsed(value => !value) : setMobileOpen(true)} edge="start">
-
-
-
-
-
- setMobileOpen(false)} ModalProps={{ keepMounted: true }} sx={{ width: desktop ? width : expandedWidth, flexShrink: 0, '& .MuiDrawer-paper': { width: desktop ? width : expandedWidth, border: 0, bgcolor: '#061a38', color: '#fff', transition: theme.transitions.create('width'), overflowX: 'hidden' } }}>
- setMobileOpen(false)} />
- {desktop ? setCollapsed(value => !value)} sx={{ position: 'absolute', right: 16, bottom: 24, color: '#a9b7cc', border: '1px solid #35506f' }}>{collapsed ? : } : null}
-
-
-
- {children}
-
-
- )
-}
diff --git a/web/src/lib/hooks.js b/web/src/lib/hooks.js
new file mode 100644
index 0000000..eeb1458
--- /dev/null
+++ b/web/src/lib/hooks.js
@@ -0,0 +1,16 @@
+import { useEffect } from 'react'
+
+export function useTitle(title) {
+ useEffect(() => { document.title = title }, [title])
+}
+
+// 401 之外的业务错误(404/409/503…)直接展示后端 message;401 在 dataProvider 里转跳登录页。
+export function displayError(error) {
+ return error?.message || '操作失败'
+}
+
+export function conflictError(error, fallback) {
+ if (error?.status === 409) return `冲突(409):${error.body?.reason || fallback}`
+ if (error?.status === 503) return `资源未就绪(503):${error.message}`
+ return displayError(error)
+}
diff --git a/web/src/lib/ui.jsx b/web/src/lib/ui.jsx
new file mode 100644
index 0000000..fc8a400
--- /dev/null
+++ b/web/src/lib/ui.jsx
@@ -0,0 +1,350 @@
+import { clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+import { createPortal } from 'react-dom'
+import { useEffect, useRef, useState } from 'react'
+
+export function cn(...inputs) {
+ return twMerge(clsx(inputs))
+}
+
+export function apiMessage(error) {
+ return error?.message || '操作失败'
+}
+
+export function conflictMessage(error, fallback) {
+ if (error?.status === 409) return `冲突(409):${fallback}`
+ if (error?.status === 503) return `环境不可用(503):${error.message}`
+ return apiMessage(error)
+}
+
+export const dateTime = value => new Date(value).toLocaleString('zh-CN')
+
+/**
+ * 页面加载/错误/空态的统一外壳。
+ */
+export function PageState({ pending, error, empty, emptyText, retryLabel = '重试', onRetry, children }) {
+ if (pending) {
+ return
+ }
+ if (error) {
+ return (
+
+ {apiMessage(error)}
+ {onRetry ? : null}
+
+ )
+ }
+ if (empty) return
+ return children
+}
+
+export function EmptyState({ text, children }) {
+ return (
+
+
+ {text}
+ {children}
+
+
+ )
+}
+
+export function Alert({ variant = 'info', className, children, ...props }) {
+ const styles = {
+ info: 'text-muted bg-[#f5f7fa] border-hairline',
+ success: 'text-success bg-[#f0f9f4] border-[#b6dfc8]',
+ warning: 'text-warning bg-[#fdf6ec] border-[#eedcbb]',
+ destructive: 'text-danger bg-[#fdf1f0] border-[#f1c7c2]',
+ }[variant]
+ return (
+
+ {children}
+
+ )
+}
+
+const buttonVariants = {
+ primary: 'bg-primary text-white hover:bg-primary-dark disabled:bg-primary/50',
+ outline: 'border border-[#c4ccd9] bg-white text-ink hover:bg-[#f5f7fa] disabled:text-muted/60 disabled:border-hairline disabled:bg-white',
+ ghost: 'text-primary hover:bg-primary/10 disabled:text-primary/50',
+ danger: 'border border-[#e4b7b2] bg-white text-danger hover:bg-[#fdf1f0] disabled:text-danger/50',
+}
+
+export function Button({ variant = 'outline', size = 'md', type = 'button', icon, as: Component = 'button', className, busy, busyText, children, ...props }) {
+ const sizes = { sm: 'h-8 px-3 text-xs', md: 'h-10 px-4 text-sm' }
+ return (
+
+ {busy ? (busyText || '处理中…') : <>{icon ? : null}{children}>}
+
+ )
+}
+
+export function Card({ className, ...props }) {
+ return
+}
+
+export function CardContent({ className, ...props }) {
+ return
+}
+
+export function PageHeader({ icon, title, description, children }) {
+ return (
+
+
+
+ {icon ? : null}
+ {title}
+
+ {description ?
{description}
: null}
+
+ {children ? {children}
: null}
+
+ )
+}
+
+/**
+ * 受控 Input / Select / Textarea。label 关联控件 id;helper 与 error 文案同槽位。
+ */
+export function Field({ id, label, required, helper, error, children, className }) {
+ const helpId = helper || error ? `${id}-help` : undefined
+ const describedBy = error ? `${id}-error` : helpId
+ const control = children
+ return (
+
+
+ {control}
+ {error ?
{error}
: helper ?
{helper}
: null}
+
+ )
+}
+
+export function Input({ invalid, className, ...props }) {
+ return
+}
+
+export function Textarea({ invalid, className, ...props }) {
+ return
+}
+
+export function Select({ id, value, onChange, options, placeholder = '请选择', emptyOption, disabled, invalid, className }) {
+ const [open, setOpen] = useState(false)
+ const [active, setActive] = useState(-1)
+ const root = useRef(null)
+ const selected = options.find(option => option.value === value) || (value === '' ? null : { value, label: value })
+ const label = selected ? selected.label : placeholder
+ const items = emptyOption !== undefined ? [{ value: emptyOption.value, label: emptyOption.label }, ...options] : options
+ const close = () => { setOpen(false); setActive(-1) }
+ const commit = item => {
+ onChange({ target: { value: item.value } })
+ close()
+ root.current?.querySelector('button')?.focus()
+ }
+ useEffect(() => {
+ if (!open) return
+ const onDocClick = event => { if (!root.current?.contains(event.target)) close() }
+ document.addEventListener('mousedown', onDocClick)
+ return () => document.removeEventListener('mousedown', onDocClick)
+ }, [open])
+ const handleKeyDown = event => {
+ if (event.key === 'Escape') {
+ event.stopPropagation()
+ close()
+ return
+ }
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ if (!open) { setOpen(true); setActive(items.findIndex(item => item.value === value)) ; return }
+ setActive(current => {
+ const next = event.key === 'ArrowDown' ? Math.min(current + 1, items.length - 1) : Math.max(current - 1, 0)
+ return next
+ })
+ return
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ if (open) {
+ const item = items[Math.max(active, 0)]
+ if (item) commit(item)
+ } else {
+ setOpen(true)
+ setActive(Math.max(items.findIndex(item => item.value === value), 0))
+ }
+ return
+ }
+ if (event.key === 'Home') setActive(0)
+ if (event.key === 'End') setActive(items.length - 1)
+ }
+ return (
+
+
+ {open ? (
+
+ {items.map((item, index) => (
+ - commit(item)}>{item.label}
+ ))}
+
+ ) : null}
+
+ )
+}
+
+/**
+ * Modal:radix Dialog 风格的轻实现。
+ */
+export function Modal({ open, onClose, title, children, footer, labelledBy, size = 'md' }) {
+ const restoreFocus = useRef(null)
+ useEffect(() => {
+ if (open) {
+ restoreFocus.current = document.activeElement
+ return
+ }
+ // 关闭时把焦点还给触发元素(与 MUI/Radix Dialog 行为一致,键盘用户不会丢失位置)
+ const trigger = restoreFocus.current
+ restoreFocus.current = null
+ if (trigger && trigger.isConnected && typeof trigger.focus === 'function') trigger.focus()
+ }, [open])
+ useEffect(() => {
+ if (!open) return
+ const onKey = event => { if (event.key === 'Escape') onClose?.() }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [open, onClose])
+ if (!open) return null
+ const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl' }
+ return createPortal(
+ { if (event.target === event.currentTarget) onClose?.() }}>
+
+
+
{title}
+
+
+
{children}
+ {footer ?
{footer}
: null}
+
+
,
+ document.body,
+ )
+}
+
+export function ConfirmDialog({ open, onClose, onConfirm, title, body, confirmLabel = '确认', busy }) {
+ return (
+
+
+
+ >}>
+ {body}
+
+ )
+}
+
+export function Copyable({ value, className }) {
+ const copy = () => navigator.clipboard?.writeText(value)
+ return (
+
+ {value}
+
+
+ )
+}
+
+export function Checkbox({ id, checked, onCheckedChange, disabled, children }) {
+ return (
+
+ )
+}
+
+export function Switch({ id, checked, onCheckedChange, disabled, 'aria-label': ariaLabel }) {
+ return (
+
+ )
+}
+
+// 枚举胶囊:运行状态、健康状态、任务状态等。
+export function StatusPill({ tone, label, sub }) {
+ const tones = {
+ success: 'text-success bg-[#f0f9f4]',
+ info: 'text-primary bg-[#eef5ff]',
+ warning: 'text-warning bg-[#fdf6ec]',
+ danger: 'text-danger bg-[#fdf1f0]',
+ neutral: 'text-muted bg-[#f5f7fa]',
+ }[tone]
+ return (
+
+
+ {label}
+ {sub ? {sub} : null}
+
+ )
+}
+
+/**
+ * 紧凑表格:列头 + 行渲染。资源域信息密度高,适合 table。列过宽时横向滚动。
+ */
+export function Table({ columns, rows, rowKey, minWidth = '900px', onRowClick, className }) {
+ return (
+
+
+
+
+ {columns.map(column => (
+ | {column.header} |
+ ))}
+
+
+
+ {rows.map(row => {
+ const clickable = onRowClick ? 'cursor-pointer hover:bg-[#f7f8fa]' : ''
+ return (
+ onRowClick(row) : undefined}>
+ {columns.map(column => (
+ | {column.render(row)} |
+ ))}
+
+ )
+ })}
+
+
+
+ )
+}
+
+export function DetailList({ rows }) {
+ return (
+
+ {rows.map(([term, value]) => (
+
+
- {term}
+ - {value ?? '—'}
+
+ ))}
+
+ )
+}
diff --git a/web/src/main.jsx b/web/src/main.jsx
index fdc1c1b..28bda02 100644
--- a/web/src/main.jsx
+++ b/web/src/main.jsx
@@ -1,44 +1,62 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-// react-admin 5.15.1 publishes an incomplete MUI barrel; ra-core is its supported headless entry point.
-import { CoreAdmin, CustomRoutes, Resource } from 'ra-core'
-import { CssBaseline, ThemeProvider } from '@mui/material'
-import { Route } from 'react-router-dom'
-import { AccountDetail, AccountList } from './AccountList'
-import { AuditList } from './AuditList'
-import { BrowserImageList } from './BrowserImageList'
-import { BrowserList } from './BrowserList'
+import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router'
+import { Refine } from '@refinedev/core'
import { dataProvider } from './dataProvider'
-import { DraftDetail } from './DraftDetail'
-import { GatewayList } from './GatewayList'
-import { CreatorHubLayout } from './layout'
-import { NetworkExitList } from './NetworkExitList'
-import { TaskDetail, TaskList } from './TaskCenter'
-import { AttemptDetail, BrowserDetail, NetworkExitDetail } from './TraceDetails'
-import { theme } from './theme'
+import { CreatorHubLayout } from './Layout'
+import { LoginPage } from './LoginPage'
+import { AccountDetail, AccountList } from './AccountsPage'
+import { AuditPage } from './AuditPage'
+import { BrowserCreatePage, BrowserDetail, BrowserList } from './BrowsersPage'
+import { BrowserImageList } from './BrowserImagesPage'
+import { DraftPage } from './DraftPage'
+import { GatewayList } from './GatewaysPage'
+import { NetworkExitDetail, NetworkExitList } from './NetworkExitsPage'
+import { TaskDetail, TaskList } from './TasksPage'
+import { AttemptDetail } from './TracePages'
+import 'remixicon/fonts/remixicon.css'
import './styles.css'
+// 开发阶段认证仅由页面层掌握:401 会清除凭证并跳转登录;localStorage 为空时直接展示登录页。
+// 不用 useState:HashRouter 的 pushState/replaceState 不触发 hashchange,状态会在导航后过期;
+// 改为每次渲染直接读 localStorage,登录后 navigate 引发重渲染即可拿到新状态。
+const authed = () => localStorage.getItem('creatorhub.auth') !== null
+
+function AuthGate({ children }) {
+ const { pathname } = useLocation()
+ const loggedIn = authed()
+ if (!loggedIn && pathname !== '/login') return
+ if (loggedIn && pathname === '/login') return
+ return children
+}
+
createRoot(document.getElementById('root')).render(
-
-
-
-
-
-
-
-
-
-
-
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+
+
+
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
,
)
diff --git a/web/src/styles.css b/web/src/styles.css
index 46686bc..a3d3e24 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -1,17 +1,25 @@
-:root {
+@import "tailwindcss";
+
+@theme {
+ --color-primary: #0866ef;
+ --color-primary-dark: #0759d4;
+ --color-success: #067647;
+ --color-warning: #b54708;
+ --color-danger: #b42318;
+ --color-ink: #111827;
+ --color-muted: #475467;
+ --color-hairline: #d7dde7;
+ --color-sidebar: #061a38;
+ --color-sidebar-hover: #0b2a54;
+}
+
+html {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- color: #111827;
+ color: var(--color-ink);
background: #fff;
- font-synthesis: none;
}
-* { box-sizing: border-box; }
-html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
-
-@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
+.anywhere {
+ overflow-wrap: anywhere;
+ word-break: break-all;
}
diff --git a/web/src/theme.js b/web/src/theme.js
deleted file mode 100644
index 17f4ea3..0000000
--- a/web/src/theme.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { createTheme } from '@mui/material/styles'
-
-export const theme = createTheme({
- palette: {
- mode: 'light',
- primary: { main: '#0866ef', dark: '#0759d4' },
- secondary: { main: '#061a38' },
- background: { default: '#ffffff', paper: '#ffffff' },
- text: { primary: '#111827', secondary: '#475467' },
- divider: '#d7dde7',
- success: { main: '#067647' },
- warning: { main: '#B54708' },
- error: { main: '#B42318' },
- },
- shape: { borderRadius: 8 },
- typography: {
- fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
- h1: { fontSize: '2.25rem', fontWeight: 760, lineHeight: 1.15, letterSpacing: '-0.035em' },
- button: { fontSize: '0.875rem', fontWeight: 650, textTransform: 'none' },
- },
- components: {
- MuiButton: {
- defaultProps: { disableElevation: true },
- styleOverrides: { root: { borderRadius: 7, minHeight: 44 } },
- },
- MuiOutlinedInput: {
- styleOverrides: { root: { borderRadius: 7 } },
- },
- MuiPaper: {
- styleOverrides: { root: { backgroundImage: 'none' } },
- },
- },
-})
diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js
index 1d2d37c..e7f72cc 100644
--- a/web/tests/responsive.e2e.js
+++ b/web/tests/responsive.e2e.js
@@ -12,6 +12,13 @@ const runtime = {
endpoint: 'http://account-a:9222',
}
+// 应用带登录门:直接预置 Basic 凭证,走与真实登录等价的 localStorage 路径
+const credential = 'user:pass'
+
+test.beforeEach(async ({ page }) => {
+ await page.addInitScript(credential => localStorage.setItem('creatorhub.auth', credential), credential)
+})
+
test('opens social-account and network-exit configuration from the main menu', async ({ page }) => {
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
@@ -106,15 +113,18 @@ test('submits the minimal social-account form at 900px', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 })
await page.goto('/#/accounts')
- await page.getByRole('textbox', { name: '账号名称', exact: true }).fill('店铺一号')
- await page.getByRole('combobox', { name: '平台类型', exact: true }).click()
+ // 简单创建走弹窗:先打开 modal 再填写
+ await page.getByRole('button', { name: '创建账号' }).click()
+ const dialog = page.getByRole('dialog')
+ await dialog.getByRole('textbox', { name: '账号名称', exact: true }).fill('店铺一号')
+ await dialog.getByRole('combobox', { name: '平台类型', exact: true }).click()
await page.getByRole('option', { name: '抖音', exact: true }).click()
- await page.getByRole('textbox', { name: '账号 ID', exact: true }).fill('shop-new')
- await page.getByRole('textbox', { name: 'TAGS', exact: true }).fill('主账号,直播')
- await page.getByRole('textbox', { name: 'Cookies', exact: true }).fill('sessionid=value; token=second')
+ await dialog.getByRole('textbox', { name: '账号 ID', exact: true }).fill('shop-new')
+ await dialog.getByRole('textbox', { name: 'TAGS', exact: true }).fill('主账号,直播')
+ await dialog.getByRole('textbox', { name: 'Cookies', exact: true }).fill('sessionid=value; token=second')
const request = page.waitForRequest(request => request.url().endsWith('/api/phase-a/accounts') && request.method() === 'POST')
- await page.getByRole('button', { name: '创建账号' }).click()
+ await dialog.getByRole('button', { name: '创建账号' }).click()
expect((await request).postDataJSON()).toEqual({ name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-new', tags: ['主账号', '直播'], cookies: 'sessionid=value; token=second' })
})
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..1923496
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.tsapp.json",
+ "include": ["tsconfig.tsapp.json", "types/*.d.ts", "scripts/*"]
+}
diff --git a/web/tsconfig.tsapp.json b/web/tsconfig.tsapp.json
new file mode 100644
index 0000000..d173cbf
--- /dev/null
+++ b/web/tsconfig.tsapp.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "allowImportingTsExtensions": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "verbatimModuleSyntax": true
+ },
+ "exclude": ["node_modules", "dist", "src", "tests"]
+}
diff --git a/web/vite.config.js b/web/vite.config.js
index 320758a..84a33d7 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -1,8 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
server: {
proxy: { '/api': 'http://127.0.0.1:8080' },
},