diff --git a/web/.umirc.ts b/web/.umirc.ts index f3b792c..361d32d 100644 --- a/web/.umirc.ts +++ b/web/.umirc.ts @@ -1,39 +1,11 @@ import { defineConfig } from '@umijs/max'; export default defineConfig({ - // ant-design-pro 同构:约定式路由 + max 内建能力,无需额外插件 + // 多入口 async chunk 间 esbuild helper 冲突(esbuild#3818,checker 直接 fatal 退出);改用 terser 压缩器绕开 + jsMinifier: "terser", + // ant-design-pro 同构:约定式路由 + max 内建能力 // MFSU 在本机反复出现 mfsu-virtual-entry 解析不到 src/.umi/umi.ts 的缓存问题,直接关闭(正确性优先) mfsu: false, - routes: [ - { path: '/login', component: './login' }, - { - path: '/', - component: './layout-wrapper', - routes: [ - { path: '/', redirect: '/accounts' }, - { path: '/accounts', component: './accounts/index' }, - { path: '/accounts/monitoring', component: './accounts/monitoring' }, - { path: '/accounts/import', component: './accounts/import' }, - { path: '/accounts/new', component: './accounts/new' }, - { path: '/accounts/:id', component: './accounts/$id' }, - { path: '/accounts/:id/edit', component: './accounts/$id/edit' }, - { path: '/tasks', component: './tasks/index' }, - { path: '/tasks/:id', component: './tasks/$id' }, - { path: '/attempts/:id', component: './attempts/$id' }, - { path: '/drafts/:id', component: './drafts/$id' }, - { path: '/browsers', component: './browsers/index' }, - { path: '/browsers/new', component: './browsers/new' }, - { path: '/browsers/:id', component: './browsers/$id' }, - { path: '/network-exits', component: './network-exits/index' }, - { path: '/network-exits/:id', component: './network-exits/$id' }, - { path: '/gateways', component: './gateways' }, - { path: '/creator/competitors', component: './creator/competitors/index' }, - { path: '/creator/competitors/:id', component: './creator/competitors/$id' }, - { path: '/creator/workbench', component: './creator/workbench' }, - { path: '/creator/settings', component: './creator/settings' }, - ], - }, - ], proxy: { '/api': { target: 'http://127.0.0.1:8082', diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx new file mode 100644 index 0000000..b4921f7 --- /dev/null +++ b/web/src/components/accounts/AccountManagementList.tsx @@ -0,0 +1,271 @@ +import { useCallback, useEffect, useState } from 'react'; +import { history } from '@umijs/max'; +import { + Alert, + Button, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import { PlusOutlined, CloudUploadOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import { getList, remove, creatorAction, creatorUpdate } from '@/services/api'; +import { conflictMessage, isCollectionAccount, platformLabel } from '@/utils/helpers'; + +// 语义对齐 web.archived/src/features/accounts/AccountsPage.jsx 的 AccountManagementList(mode: owned/monitoring)。 +// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。 +interface Row { + id: string; + name: string; + platform: string; + platform_account_key: string; + tags?: string[]; + source_type: 'owned' | 'monitoring'; + runtime_status?: string; + authorization_status?: string; + enabled?: boolean; + nickname?: string; +} + +export default function AccountManagementList({ mode }: { mode: 'owned' | 'monitoring' }) { + const isMonitoring = mode === 'monitoring'; + const [rows, setRows] = useState([]); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [tagTarget, setTagTarget] = useState(null); + const [tagForm] = Form.useForm<{ tags: string[] }>(); + const [tagBusy, setTagBusy] = useState(false); + const [actionBusy, setActionBusy] = useState(''); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(async () => { + setPending(true); + setError(null); + try { + const resource = isMonitoring ? 'creator-competitors' : 'accounts'; + const result = await getList({ resource: resource as any }); + setRows( + (result.data ?? []).map((account: any) => ({ + ...account, + name: isMonitoring ? account.nickname || account.platform_account_key : account.name, + source_type: isMonitoring ? 'monitoring' : 'owned', + tags: account.tags ?? [], + })), + ); + } catch (loadError) { + setError(loadError); + } finally { + setPending(false); + } + }, [isMonitoring]); + + useEffect(() => { + load(); + }, [load]); + + async function syncCompetitor(competitor: Row) { + setActionBusy(competitor.id); + try { + const result = await getList({ resource: 'creator-accounts' }); + const account = (result.data ?? []).find( + (candidate: any) => candidate.platform === competitor.platform && isCollectionAccount(candidate), + ); + if (!account) { + throw new Error(`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`); + } + await creatorAction(`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`, { account_id: account.id }); + messageApi.success('监测账号同步任务已提交。'); + await load(); + } catch (actionError) { + messageApi.error(conflictMessage(actionError, '监测账号同步失败')); + } finally { + setActionBusy(''); + } + } + + async function toggleCompetitor(competitor: Row) { + setActionBusy(competitor.id); + try { + await creatorAction( + `/creator/competitors/${encodeURIComponent(competitor.id)}/${competitor.enabled ? 'pause' : 'resume'}`, + ); + messageApi.success(competitor.enabled ? '监测账号已暂停。' : '监测账号已恢复。'); + await load(); + } catch (actionError) { + messageApi.error(conflictMessage(actionError, '监测账号状态更新失败')); + } finally { + setActionBusy(''); + } + } + + async function deleteAccount(row: Row) { + const label = row.source_type === 'owned' ? '自有账号' : '监测账号'; + setActionBusy(`${row.source_type}:${row.id}`); + try { + await remove(row.source_type === 'owned' ? 'accounts' : 'creator-competitors', row.id); + messageApi.success(`${label}已删除。`); + await load(); + } catch (deleteError) { + messageApi.error(conflictMessage(deleteError, `${label}删除失败`)); + } finally { + setActionBusy(''); + } + } + + function openTags(row: Row) { + setTagTarget(row); + tagForm.setFieldsValue({ tags: Array.isArray(row.tags) ? row.tags : [] }); + } + + async function saveTags() { + if (!tagTarget) return; + const { tags } = await tagForm.validateFields(); + setTagBusy(true); + try { + await (tagTarget.source_type === 'owned' + ? creatorUpdate(`/creator/accounts/${encodeURIComponent(tagTarget.id)}/tags`, { tags }) + : creatorUpdate(`/creator/competitors/${encodeURIComponent(tagTarget.id)}`, { tags })); + setRows((current) => current.map((row) => (row.id === tagTarget.id ? { ...row, tags } : row))); + setTagTarget(null); + messageApi.success('标签已保存。'); + } catch (saveError) { + messageApi.error(conflictMessage(saveError, '标签保存失败')); + } finally { + setTagBusy(false); + } + } + + const columns: ColumnsType = [ + { + title: '账号', + dataIndex: 'name', + render: (_, account) => ( +
+ {account.source_type === 'owned' ? ( + history.push(`/accounts/${account.id}`)}>{account.name} + ) : ( + {account.name} + )} + + {account.platform_account_key} + +
+ ), + }, + { title: '平台', dataIndex: 'platform', render: (value: string) => platformLabel(value) }, + { + title: '标签', + dataIndex: 'tags', + render: (tags?: string[]) => (tags?.length ? tags.map((tag) => {tag}) : 未设置), + }, + { + title: '状态', + dataIndex: 'status', + render: (_, account) => + account.source_type === 'owned' ? ( + + + {account.runtime_status === 'active' ? '启用' : '暂停'} + + {account.authorization_status === 'authorized' ? '已授权' : '已撤销'} + + ) : ( + {account.enabled ? '监控中' : '已暂停'} + ), + }, + { + title: '操作', + key: 'actions', + align: 'right', + render: (_, account) => ( + + + {account.source_type === 'owned' ? ( + <> + + + + ) : ( + <> + + + + )} + deleteAccount(account)} + disabled={actionBusy === `${account.source_type}:${account.id}`} + > + + + + ), + }, + ]; + + return ( +
+ {contextHolder} +
+ + 共 {rows.length} 个{isMonitoring ? '监控' : '自有'}账号 + + {isMonitoring ? ( + + ) : ( + + )} +
+ {error ? ( + 重试} /> + ) : null} + `${row.source_type}:${row.id}`} + columns={columns} + dataSource={rows} + loading={pending} + pagination={false} + locale={{ emptyText: isMonitoring ? '暂无监控账号。' : '暂无自有账号。' }} + /> + { + if (!tagBusy) setTagTarget(null); + }} + onOk={saveTags} + confirmLoading={tagBusy} + okText={tagBusy ? '保存中…' : '保存标签'} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 最近更新:{selected.updated_at ? dateTime(selected.updated_at) : '—'} + +
+ + + + ); + + const strategyTab = selected.big_account ? ( +
+ + 先绑定同平台小号,再保存单动作策略;没有平台执行证据时只记录为不确定。 +
+ + + + + + + + + + + + + + {editingStrategyID ? ( + + ) : null} + + + {strategyError ? : null} + {relationError ? : null} + {relations.map((relation) => ( + +
+ 执行账号:{relation.small_account_id} + +
+
+ ))} + {strategies.map((strategy) => ( + +
+ + + #{strategy.position} · {strategy.action} · {strategy.execution_account_id} · {(strategy.event_types || []).join('、')} + + {strategy.enabled ? '启用' : '停用'} + + + + + deleteStrategy(strategy)}> + + + +
+
+ ))} + {!strategies.length && !strategyError ? 尚未配置策略。 : null} +
+
+ ) : ( + 开启大号模式后可配置有序自动响应策略。 + ); + + return ( +
+ {contextHolder} +
+
+ + {selected.name || selected.platform_account_key} + + + {platformLabelMap[selected.platform] || selected.platform} · {selected.platform_account_key} + +
+ +
+ +
+ ); } diff --git a/web/src/pages/accounts/$id/index.tsx b/web/src/pages/accounts/$id/index.tsx new file mode 100644 index 0000000..2dba860 --- /dev/null +++ b/web/src/pages/accounts/$id/index.tsx @@ -0,0 +1,260 @@ +// 账号详情:语义对齐 web.archived AccountDetail。 +// 三卡布局改用 antd Tabs(账号状态 / 固定资源 / 文本草稿)——antd 默认组件无三列等宽卡片栅格约束,Tabs 是标准信息组织方式。 +import { useCallback, useEffect, useState } from 'react'; +import { history, useParams } from '@umijs/max'; +import { + Alert, + Button, + Card, + Descriptions, + Form, + Input, + Modal, + Space, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import { accountAction, createDraft, getOne, getList } from '@/services/api'; +import { accountReadiness, conflictMessage, dateTime } from '@/utils/helpers'; + +export default function Page() { + const { id = '' } = useParams<{ id: string }>(); + const [account, setAccount] = useState(null); + const [binding, setBinding] = useState(null); + const [browsersError, setBrowsersError] = useState(null); + const [drafts, setDrafts] = useState([]); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [pauseOpen, setPauseOpen] = useState(false); + const [actionBusy, setActionBusy] = useState(false); + const [draftForm] = Form.useForm<{ content: string }>(); + const [draftBusy, setDraftBusy] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const readiness = account ? accountReadiness(account, binding, browsersError) : null; + + const loadAll = useCallback(async () => { + setPending(true); + setError(null); + try { + const [accountResult, browsersResult] = await Promise.allSettled([ + getOne('accounts', id), + getList({ resource: 'browsers' }), + ]); + if (accountResult.status === 'fulfilled') { + setAccount(accountResult.value); + setError(null); + } else { + setError(accountResult.reason); + } + if (browsersResult.status === 'fulfilled') { + setBrowsersError(null); + setBinding((browsersResult.value.data ?? []).find((item: any) => item.account_id === id) ?? null); + } else { + setBrowsersError(browsersResult.reason); + setBinding(undefined); + } + try { + const draftResult = await getList({ resource: 'drafts', filters: { account_id: id } }); + setDrafts(draftResult.data ?? []); + } catch { + setDrafts([]); + } + } finally { + setPending(false); + } + }, [id]); + + useEffect(() => { + loadAll(); + }, [loadAll]); + + async function runAction(action: 'pause' | 'resume') { + setActionBusy(true); + try { + await accountAction(account.id, action); + messageApi.success( + action === 'pause' ? '账号已暂停;既有 hold 任务需后续逐条核验。' : '账号已恢复;既有 hold 任务没有自动恢复。', + ); + await loadAll(); + } catch (reason) { + messageApi.error(conflictMessage(reason)); + } finally { + setActionBusy(false); + } + } + + async function onCreateDraft() { + if (!readiness?.ready) return; + const { content } = await draftForm.validateFields(); + setDraftBusy(true); + try { + const draft = await createDraft(account.id, content); + await loadAll(); + draftForm.resetFields(); + messageApi.success(`草稿版本 ${draft.version} 已创建,请进入只读快照核对。`); + } catch (reason) { + messageApi.error(conflictMessage(reason)); + } finally { + setDraftBusy(false); + } + } + + if (pending) return ; + if (error || !account) return ; + + const statusTag = ( + + {readiness?.label} + + ); + + const items = [ + { + key: 'status', + label: '账号状态', + children: ( +
+ +
{statusTag}
+ + {account.authorization_status === 'authorized' ? '已授权' : '已撤销'} + {`${account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 ${account.version}`} + {account.tags?.join('、') || '无'} + + + + + + +
+
+ ), + }, + { + key: 'binding', + label: '固定资源', + children: browsersError ? ( + + 重试环境状态 + + } + /> + ) : binding ? ( +
+ + {`${binding.name}(${binding.alias})`} + + {binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'} + + {binding.binding_version} + + {binding.schedule_block_reason || '无'} + + + history.push('/browsers')} style={{ display: 'inline-block', marginTop: 12 }}> + 前往运行环境 + +
+ ) : ( +
+ 尚未绑定运行环境。账号暂停且资源就绪后,可在运行环境页创建绑定。 + history.push('/browsers')}>前往运行环境 +
+ ), + }, + { + key: 'drafts', + label: '文本草稿', + children: ( +
+ + 仅支持单用户 Phase A Mock 文本;内部 ID 与版本由系统生成。 +
+ + + + + + 尚无草稿时无需处理;创建后请进入草稿核对。 + {drafts.map((draft) => ( + +
+
+ 草稿版本 {draft.version} + + {draft.content} + +
+ history.push(`/drafts/${draft.id}`)} style={{ flexShrink: 0 }}> + 核对草稿 + +
+
+ ))} +
+
+ ), + }, + ]; + + return ( +
+ {contextHolder} +
+
+ + {account.name} + + + {account.platform_account_key} · {account.platform} + +
+ + + + +
+ + setPauseOpen(false)} + onOk={() => { + setPauseOpen(false); + runAction('pause'); + }} + okText="确认暂停" + okButtonProps={{ danger: true }} + > +

{`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`}

+
+
+ ); +} diff --git a/web/src/pages/accounts/import.tsx b/web/src/pages/accounts/import.tsx index a19f23c..b6f4c54 100644 --- a/web/src/pages/accounts/import.tsx +++ b/web/src/pages/accounts/import.tsx @@ -1,4 +1,211 @@ -// TODO: 从 web.archived 迁移 accounts/import +// 账号导入:语义对齐 web.archived AccountImportPage(分享链接解析任务列表 + 新建导入任务 Modal)。 +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Button, + Form, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import { create, getList, creatorAction } from '@/services/api'; +import { conflictMessage, dateTime, extractShareURL, platformForShareURL, platformLabel, shareJobStatus } from '@/utils/helpers'; + +interface Job { + id: string; + share_url: string; + platform?: string; + status?: string; + attempts?: number; + failure_reason?: string; + created_at?: string; +} + +interface FormValues { + shareText: string; + tags?: string[]; +} + export default function Page() { - return null; + const [jobs, setJobs] = useState([]); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [form] = Form.useForm(); + const [busy, setBusy] = useState(false); + const [retryID, setRetryID] = useState(null); + const [messageApi, contextHolder] = message.useMessage(); + + const loadShareJobs = useCallback(async () => { + setPending(true); + setError(null); + try { + const result = await getList({ resource: 'creator-competitor-share-jobs' }); + setJobs(result.data ?? []); + } catch (loadError) { + setError(loadError); + } finally { + setPending(false); + } + }, []); + + useEffect(() => { + loadShareJobs(); + }, [loadShareJobs]); + + const shareText = Form.useWatch('shareText', form) ?? ''; + const extractedShareURL = extractShareURL(shareText); + const sharePlatform = platformForShareURL(extractedShareURL); + + const closeCreate = () => { + if (busy) return; + setCreateOpen(false); + form.resetFields(); + }; + + async function createJob() { + if (!extractedShareURL) { + messageApi.warning('请粘贴包含分享链接的内容。'); + return; + } + if (!sharePlatform) { + messageApi.warning('链接不是支持的平台分享链接。'); + return; + } + const values = await form.validateFields(); + setBusy(true); + try { + const variables: Record = { + platform: sharePlatform, + share_url: extractedShareURL, + }; + if (values.tags?.length) variables.tags = values.tags; + await create('creator-competitor-share-jobs', variables); + await loadShareJobs(); + setCreateOpen(false); + form.resetFields(); + messageApi.success('分享链接已加入作者解析队列。'); + } catch (createError) { + messageApi.error(conflictMessage(createError, '分享链接入队失败')); + } finally { + setBusy(false); + } + } + + async function retryShareJob(jobID: string) { + setRetryID(jobID); + try { + await creatorAction(`/creator/competitor-share-jobs/${encodeURIComponent(jobID)}/retry`); + await loadShareJobs(); + messageApi.success('任务已重新加入解析队列。'); + } catch (retryError) { + messageApi.error(conflictMessage(retryError, '任务重新入队失败')); + } finally { + setRetryID(null); + } + } + + const columns: ColumnsType = [ + { + title: '分享链接', + dataIndex: 'share_url', + width: 280, + render: (_, job) => ( +
+ + {job.share_url} + + {dateTime(job.created_at)} +
+ ), + }, + { title: '平台', dataIndex: 'platform', render: (value?: string) => platformLabel(value) }, + { + title: '状态', + dataIndex: 'status', + render: (value?: string) => { + const status = shareJobStatus[value ?? ''] || { label: value || '未知', tone: 'default' as const }; + return {status.label}; + }, + }, + { title: '尝试次数', dataIndex: 'attempts', render: (value?: number) => {value ?? 0} / 3 }, + { + title: '失败原因', + dataIndex: 'failure_reason', + render: (value?: string) => (value ? {value} : '—'), + }, + { + title: '操作', + key: 'actions', + align: 'right', + render: (_, job) => + job.status === 'failed' ? ( + + ) : ( + '—' + ), + }, + ]; + + return ( +
+ {contextHolder} +
+ +
+
+
+ 分享链接解析任务 + 待处理和失败的分享链接独立展示;成功后才会进入竞品作者监控。 +
+ +
+ {error ? ( + 重试} /> + ) : null} +
+ +
+ + + + {shareText ? ( + extractedShareURL ? ( + + 已提取链接:{extractedShareURL} + {sharePlatform ? ` · ${sharePlatform === 'douyin' ? '抖音' : '小红书'}` : ' · 待识别平台'} + + ) : ( + 未找到可用的 http(s) 分享链接。 + ) + ) : null} + + + + + + + + - - - - - - - - - ); -} diff --git a/web/src/pages/network-exits/$id.tsx b/web/src/pages/network-exits/$id/index.tsx similarity index 100% rename from web/src/pages/network-exits/$id.tsx rename to web/src/pages/network-exits/$id/index.tsx diff --git a/web/src/pages/tasks/$id.tsx b/web/src/pages/tasks/$id/index.tsx similarity index 100% rename from web/src/pages/tasks/$id.tsx rename to web/src/pages/tasks/$id/index.tsx diff --git a/web/src/utils/helpers.ts b/web/src/utils/helpers.ts new file mode 100644 index 0000000..aeccd99 --- /dev/null +++ b/web/src/utils/helpers.ts @@ -0,0 +1,109 @@ +// 公共展示工具:字段语义逐一对照 web.archived/src/shared/ui/ui.jsx +export function dateTime(value?: string | number | null): string { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(); +} + +// 对齐归档版 conflictMessage:优先后端 error 字段,409 冲突时附加提示。 +export function conflictMessage(error: any, conflictHint?: string): string { + const status = error?.status ?? error?.body?.status; + const message = error?.message || error?.body?.error || '请求失败'; + if (status === 409 && conflictHint) return `${message}:${conflictHint}`; + return message; +} + +export const platforms = [ + { value: 'douyin', label: '抖音' }, + { value: 'xiaohongshu', label: '小红书' }, + { value: 'wechat-official', label: '公众号' }, + { value: 'kuaishou', label: '快手' }, +]; + +export function platformLabel(value?: string): string { + return platforms.find((item) => item.value === value)?.label || value || '—'; +} + +export const shareJobStatus: Record = { + queued: { label: '待解析', tone: 'warning' }, + processing: { label: '解析中', tone: 'warning' }, + succeeded: { label: '已加入监控', tone: 'success' }, + failed: { label: '解析失败', tone: 'error' }, +}; + +// 对齐归档版 extractShareURL / platformForShareURL +const shareURLPattern = /https?:\/\/[^\s<>"'`]+/giu; +const trailingShareURLPunctuation = /[,。;!?、)》)】\]}>,.?!:;]+$/u; + +export function platformForShareURL(value: string): string { + try { + const hostname = new URL(value).hostname.toLowerCase(); + if (['www.douyin.com', 'v.douyin.com'].includes(hostname)) return 'douyin'; + if (['www.xiaohongshu.com', 'xhslink.com', 'www.xhslink.com'].includes(hostname)) return 'xiaohongshu'; + } catch { + return ''; + } + return ''; +} + +export function extractShareURL(value?: string): string { + const candidates = String(value || '') + .match(shareURLPattern) + ?.map((candidate) => candidate.replace(trailingShareURLPunctuation, '')) + .filter(Boolean); + return candidates?.find((candidate) => platformForShareURL(candidate)) || candidates?.[0] || ''; +} + +// 账号可恢复性判定(对齐归档版 accountReadiness):授权、绑定、出口健康、账号暂停四层。 +export const readinessReasonText: Record = { + account_revoked: '授权已撤销', + account_paused: '账号已暂停', + binding_missing: '未绑定运行环境', + network_exit_missing: '未绑定固定出口', + network_exit_unhealthy: '固定出口不健康', + runtime_stop_pending: '运行环境停止结果待确认', + runtime_missing: '运行环境未启动', + runtime_active: '仍有活动运行实例', + account_conflict: '账号状态已变化,请刷新后重试', + environment_unavailable: '环境状态未知', +}; + +export interface AccountReadiness { + label: string; + reason: string; + canResume: boolean; + ready: boolean; +} + +export function accountReadiness(account: any, binding: any, bindingError: any = null): AccountReadiness { + if (account.authorization_status !== 'authorized') + return { label: '授权已撤销', reason: 'account_revoked', canResume: false, ready: false }; + if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false }; + if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false }; + let blocked = ''; + if (binding.cleanup_pending) { + blocked = 'runtime_stop_pending'; + } else if (binding.network_exit_id && binding.network_exit_health !== 'healthy') { + blocked = 'network_exit_unhealthy'; + } + if (blocked) return { label: readinessReasonText[blocked], reason: blocked, canResume: false, ready: false }; + if (account.runtime_status === 'paused') { + const stopped = !binding.runtime_instance_id; + return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, ready: false }; + } + const schedule = binding.schedule_block_reason || ''; + return { + label: schedule ? readinessReasonText[schedule] || `不可调度:${schedule}` : '可调度', + reason: schedule, + canResume: false, + ready: !schedule, + }; +} + +export function isCollectionAccount(account: any): boolean { + return ( + account.authorization_status === 'authorized' && + ['normal', 'muted'].includes(account.business_status) && + account.login_status === 'logged_in' + ); +} diff --git a/web/src/utils/listQuery.ts b/web/src/utils/listQuery.ts new file mode 100644 index 0000000..68b1e6e --- /dev/null +++ b/web/src/utils/listQuery.ts @@ -0,0 +1,25 @@ +// 列表页共享工具:资源 → 查询字段白名单,对齐 web.archived/src/shared/api/dataProvider.js 的 filterKeys。 +// creator-* 资源走 page/page_size 分页;其余资源后端返回全量或自带 total。 +export const filterKeys: Record = { + drafts: ['account_id'], + confirmations: ['draft_id'], + tasks: ['account_id', 'draft_id', 'state'], + 'creator-competitors': ['platform'], + 'creator-competitor-share-jobs': ['platform', 'status'], + 'creator-works': ['platform', 'source_id', 'source_type', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares'], + 'creator-comments': ['platform', 'work_id'], + 'creator-leads': ['platform'], + 'creator-operations': ['account_id'], + 'creator-conversations': ['account_id'], + 'creator-events': ['account_id'], +}; + +export function isCreatorResource(resource: string): boolean { + return resource.startsWith('creator-'); +} + +export interface ListQuery { + data: any[]; + total: number; + has_next?: boolean; +} diff --git a/web/src/utils/metadata.ts b/web/src/utils/metadata.ts new file mode 100644 index 0000000..973a7f9 --- /dev/null +++ b/web/src/utils/metadata.ts @@ -0,0 +1,60 @@ +import type { MenuDataItem } from '@ant-design/pro-components'; + +// 页头标题/副标题规则:语义对齐 web.archived/src/app/Layout.jsx 的 pageMetadataRules。 +const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: string }> = [ + { test: (p) => p === '/accounts/import', title: '账号导入', subtitle: '管理分享链接解析任务,解析成功后自动加入监控账号。' }, + { test: (p) => p === '/accounts', title: '我的账号', subtitle: '创建和管理自己维护的账号。' }, + { test: (p) => p === '/accounts/monitoring', title: '监控账号', subtitle: '管理需要持续跟踪的竞品账号及采集状态。' }, + { test: (p) => p === '/accounts/new', title: '创建社媒账号', subtitle: '创建账号后,再在编辑页配置登录身份与账号策略。' }, + { test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' }, + { test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' }, + { test: (p) => p === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' }, + { test: (p) => p === '/creator/workbench', title: '运营工作台', subtitle: '评论、线索、私信与写操作均保留来源和明确结果。' }, + { test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' }, + { test: (p) => p === '/competitors' || p.startsWith('/competitors/'), title: '竞品分析', subtitle: '粘贴作品分享内容,自动识别作者并加入监听队列。' }, + { test: (p) => p.startsWith('/drafts/'), title: '草稿核对', subtitle: '核对版本化草稿,确认后再将操作加入队列。' }, + { test: (p) => p.startsWith('/attempts/'), title: 'Attempt 详情', subtitle: '查看执行结果、运行环境与网络出口。' }, + { test: (p) => p === '/tasks', title: '任务中心', subtitle: '逐条处理暂停、版本变化与未知执行结果。' }, + { test: (p) => p.startsWith('/tasks/'), title: '任务详情', subtitle: '查看任务状态、执行结果与允许的下一步操作。' }, + { test: (p) => p === '/browsers', title: '运行环境', subtitle: '启动、停止并回收隔离的指纹浏览器运行环境。' }, + { test: (p) => p === '/browsers/new', title: '创建运行环境', subtitle: '为已授权且暂停的账号绑定网关、指纹与网络出口。' }, + { test: (p) => p.startsWith('/browsers/'), title: '运行环境详情', subtitle: '查看环境状态、账号绑定和运行实例信息。' }, + { test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' }, + { test: (p) => p.startsWith('/network-exits/'), title: '网络出口详情', subtitle: '查看出口配置、健康状态和绑定关系。' }, + { test: (p) => p === '/gateways', title: '网关管理', subtitle: '注册并检查宿主机 native browser gateway。' }, + { test: (p) => p.startsWith('/gateways/'), title: '网关详情', subtitle: '查看网关连接信息和运行状态。' }, +]; + +// 菜单结构:与 web.archived/src/app/Layout.jsx 完全一致(运营 / 资源 两组)。 +export const menu: MenuDataItem[] = [ + { + name: '运营', + children: [ + { path: '/accounts/import', name: '账号导入' }, + { path: '/accounts', name: '我的账号' }, + { path: '/accounts/monitoring', name: '监控账号' }, + { path: '/creator/competitors', name: '竞品分析' }, + { path: '/creator/workbench', name: '运营工作台' }, + { path: '/creator/settings', name: '采集设置' }, + { path: '/tasks', name: '任务中心' }, + ], + }, + { + name: '资源', + children: [ + { path: '/browsers', name: '运行环境' }, + { path: '/network-exits', name: '网络出口' }, + { path: '/gateways', name: '网关管理' }, + ], + }, +]; + +export function pageMetadata(pathname: string): { title: string; subtitle: string } { + const matched = rules.find((item) => item.test(pathname)); + if (matched) return { title: matched.title, subtitle: matched.subtitle }; + for (const group of menu) { + const hit = (group.children ?? []).find((item) => item.path && (pathname === item.path || pathname.startsWith(`${item.path}/`))); + if (hit) return { title: hit.name ?? '', subtitle: 'CreatorHub 运营控制台' }; + } + return { title: 'CreatorHub', subtitle: '运营控制台' }; +}