feat(web): task-3 accounts 模块全量迁移(列表/监控/导入/新建/详情/编辑,antd 默认组件 + 约定式路由 + terser 压缩绕开 esbuild helper 冲突)

This commit is contained in:
2026-09-22 12:40:09 +08:00
parent 542eac9e0a
commit 04ac91874a
22 changed files with 1551 additions and 133 deletions
+3 -31
View File
@@ -1,39 +1,11 @@
import { defineConfig } from '@umijs/max';
export default defineConfig({
// ant-design-pro 同构:约定式路由 + max 内建能力,无需额外插件
// 多入口 async chunk 间 esbuild helper 冲突(esbuild#3818checker 直接 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',
@@ -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 的 AccountManagementListmode: 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<Row[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [tagTarget, setTagTarget] = useState<Row | null>(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<Row> = [
{
title: '账号',
dataIndex: 'name',
render: (_, account) => (
<div>
{account.source_type === 'owned' ? (
<Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name}</Typography.Link>
) : (
<span>{account.name}</span>
)}
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }} ellipsis>
{account.platform_account_key}
</Typography.Paragraph>
</div>
),
},
{ title: '平台', dataIndex: 'platform', render: (value: string) => platformLabel(value) },
{
title: '标签',
dataIndex: 'tags',
render: (tags?: string[]) => (tags?.length ? tags.map((tag) => <Tag key={tag}>{tag}</Tag>) : <Typography.Text type="secondary"></Typography.Text>),
},
{
title: '状态',
dataIndex: 'status',
render: (_, account) =>
account.source_type === 'owned' ? (
<Space direction="vertical" size={0}>
<Tag color={account.runtime_status === 'active' ? 'success' : 'default'}>
{account.runtime_status === 'active' ? '启用' : '暂停'}
</Tag>
<Typography.Text type="secondary">{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}</Typography.Text>
</Space>
) : (
<Tag color={account.enabled ? 'success' : 'default'}>{account.enabled ? '监控中' : '已暂停'}</Tag>
),
},
{
title: '操作',
key: 'actions',
align: 'right',
render: (_, account) => (
<Space wrap>
<Button size="small" onClick={() => openTags(account)}>
</Button>
{account.source_type === 'owned' ? (
<>
<Button size="small" onClick={() => history.push(`/accounts/${account.id}/edit`)}>
</Button>
<Button size="small" onClick={() => history.push(`/accounts/${account.id}`)}>
</Button>
</>
) : (
<>
<Button size="small" disabled={actionBusy === account.id} onClick={() => syncCompetitor(account)}>
</Button>
<Button size="small" disabled={actionBusy === account.id} onClick={() => toggleCompetitor(account)}>
{account.enabled ? '暂停' : '恢复'}
</Button>
</>
)}
<Popconfirm
title={`确定删除${account.source_type === 'owned' ? '自有账号' : '监测账号'}${account.name}”吗?相关专属资源也会被清理。`}
onConfirm={() => deleteAccount(account)}
disabled={actionBusy === `${account.source_type}:${account.id}`}
>
<Button size="small" danger disabled={actionBusy === `${account.source_type}:${account.id}`}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
{rows.length} {isMonitoring ? '监控' : '自有'}
</Typography.Text>
{isMonitoring ? (
<Button type="primary" icon={<CloudUploadOutlined />} onClick={() => history.push('/accounts/import')}>
</Button>
) : (
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/accounts/new')}>
</Button>
)}
</div>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}></Button>} />
) : null}
<Table
rowKey={(row) => `${row.source_type}:${row.id}`}
columns={columns}
dataSource={rows}
loading={pending}
pagination={false}
locale={{ emptyText: isMonitoring ? '暂无监控账号。' : '暂无自有账号。' }}
/>
<Modal
open={Boolean(tagTarget)}
title={`编辑标签 · ${tagTarget?.name || '账号'}`}
onCancel={() => {
if (!tagBusy) setTagTarget(null);
}}
onOk={saveTags}
confirmLoading={tagBusy}
okText={tagBusy ? '保存中…' : '保存标签'}
>
<Form form={tagForm} layout="vertical">
<Form.Item name="tags" label="标签" extra="回车或逗号确认,最多 20 个标签,每个最多 32 个字符。">
<Select mode="tags" tokenSeparators={[',', '']} placeholder="如:主账号" open={false} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,45 +1,41 @@
import { useRef, useState } from 'react';
import { history } from '@umijs/max';
import { ProLayout } from '@ant-design/pro-components';
import { PageContainer, ProLayout } from '@ant-design/pro-components';
import { Dropdown } from 'antd';
import { LogoutOutlined } from '@ant-design/icons';
import type { MenuDataItem } from '@ant-design/pro-components';
import type { ReactNode } from 'react';
import { pageMetadata, menu } from '@/utils/metadata';
// 菜单结构与 web.archived/src/app/Layout.jsx 完全一致(运营 / 资源 两组)。
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: '网关管理' },
],
},
];
// 菜单结构与 web.archived/src/app/Layout.jsx 完全一致(运营 / 资源 两组),页头标题规则对齐 pageMetadataRules
// /login 独立页:不走 ProLayout(对齐归档版布局分支)。
export default function Layout({ children, location }: { children: ReactNode; location: { pathname: string } }) {
if (location.pathname === '/login') return <>{children}</>;
const metadata = pageMetadata(location.pathname);
export default function Layout({ children }: { children: ReactNode }) {
return (
<ProLayout
title="CreatorHub"
logo={false}
layout="mix"
fixSiderbar
route={{ routes: menu }}
route={{ routes: menu } as any}
location={location}
menuItemRender={(item, dom) => (
<a onClick={() => item.path && history.push(item.path)}>{dom}</a>
)}
menuHeaderRender={(logo, title) => (
<a
style={{ display: 'flex', alignItems: 'center', gap: 8 }}
onClick={() => history.push('/accounts')}
>
{logo}
<span>
<div style={{ fontWeight: 600, fontSize: 14 }}>{title}</div>
<div style={{ fontSize: 12, opacity: 0.6 }}></div>
</span>
</a>
)}
avatarProps={{
icon: <LogoutOutlined />,
size: 'small',
@@ -64,7 +60,9 @@ export default function Layout({ children }: { children: ReactNode }) {
),
}}
>
{children}
<PageContainer title={metadata.title} subTitle={metadata.subtitle} ghost>
{children}
</PageContainer>
</ProLayout>
);
}
-4
View File
@@ -1,4 +0,0 @@
// TODO: 从 web.archived 迁移 accounts/$id
export default function Page() {
return null;
}
+500 -2
View File
@@ -1,4 +1,502 @@
// TODO: 从 web.archived 迁移 accounts/$id/edit
// 编辑社媒账号:语义对齐 web.archived AccountEditPage。
// 布局改用 antd Tabs(账号资料 / 自动响应策略)——归档版三区块信息量超过单页栅格,Tabs 是 antd 标准组织方式。
import { useCallback, useEffect, useState } from 'react';
import { history, useParams } from '@umijs/max';
import {
Alert,
Button,
Card,
Checkbox,
Descriptions,
Form,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import { creatorAction, creatorCreate, creatorGet, creatorRequest, creatorUpdate, getList } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
const platformLabelMap: Record<string, string> = { douyin: '抖音', xiaohongshu: '小红书' };
const eventTypeOptions = [
{ value: 'comment', label: '评论' },
{ value: 'like', label: '点赞' },
{ value: 'repost', label: '转发' },
{ value: 'follow', label: '关注' },
];
function profileForm(profile: any) {
return {
login_username: profile.login_username || '',
password: '',
real_name_status: profile.real_name_status || 'unknown',
real_name: profile.real_name || '',
identity_number: profile.identity_number || '',
note: profile.note || '',
business_status: profile.business_status || 'normal',
reply_requirements: profile.reply_requirements || '',
cooldown_seconds: profile.cooldown_seconds || 86400,
};
}
export default function Page() {
return null;
const { id: selectedID = '' } = useParams<{ id: string }>();
const [profiles, setProfiles] = useState<any[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [loginQR, setLoginQR] = useState<any>(null);
const [form] = Form.useForm<any>();
const [strategyForm] = Form.useForm<any>();
const [strategies, setStrategies] = useState<any[]>([]);
const [strategyError, setStrategyError] = useState<any>(null);
const [editingStrategyID, setEditingStrategyID] = useState('');
const [relations, setRelations] = useState<any[]>([]);
const [relationError, setRelationError] = useState<any>(null);
const [listener, setListener] = useState<any>(null);
const [listenerError, setListenerError] = useState<any>(null);
const [messageApi, contextHolder] = message.useMessage();
const selected = profiles.find((item) => item.id === selectedID);
const load = useCallback(async () => {
setPending(true);
setError(null);
try {
const result = await getList({ resource: 'creator-accounts' });
setProfiles(result.data);
const hit = result.data.find((item: any) => item.id === selectedID);
if (hit) {
form.setFieldsValue(profileForm(hit));
} else {
setError(new Error('账号不存在'));
}
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
}, [selectedID, form]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
if (!selectedID) return;
creatorGet(`/creator/accounts/${encodeURIComponent(selectedID)}/strategies`)
.then((result) => setStrategies(result))
.catch((loadError) => setStrategyError(loadError));
}, [selectedID]);
useEffect(() => {
if (!selectedID) return;
creatorGet(`/creator/listeners?account_id=${encodeURIComponent(selectedID)}`)
.then((result) => setListener(result[0] || null))
.catch((loadError) => setListenerError(loadError));
}, [selectedID]);
useEffect(() => {
if (!selectedID) return;
creatorGet(`/creator/relations?big_account_id=${encodeURIComponent(selectedID)}`)
.then((result) => setRelations(result))
.catch((loadError) => setRelationError(loadError));
}, [selectedID]);
async function save(values: any) {
if (!selected) return;
setBusy(true);
try {
const result = await creatorUpdate(`/creator/accounts/${encodeURIComponent(selected.id)}/profile`, {
...values,
cooldown_seconds: Number(values.cooldown_seconds),
});
setProfiles((items) => items.map((item) => (item.id === selected.id ? result : item)));
form.setFieldsValue(profileForm(result));
messageApi.success('账号资料已保存。');
} catch (saveError) {
messageApi.error(conflictMessage(saveError, '账号资料保存失败'));
} finally {
setBusy(false);
}
}
async function requestLoginQR() {
if (!selected || selected.platform !== 'douyin') return;
setBusy(true);
try {
const result = await creatorAction(`/creator/accounts/${encodeURIComponent(selected.id)}/login-qr`);
setLoginQR(result);
messageApi.success(
result.qr_detected ? '登录二维码已生成,请使用抖音 App 扫码。' : '登录画面已生成;当前未检测到二维码,请按页面提示人工完成验证。',
);
} catch (qrError) {
messageApi.error(conflictMessage(qrError, '登录二维码获取失败;请检查运行环境和网关状态'));
} finally {
setBusy(false);
}
}
async function verifyLogin() {
if (!selected) return;
setBusy(true);
try {
const result = await creatorAction(`/creator/accounts/${encodeURIComponent(selected.id)}/verify`);
setProfiles((items) =>
items.map((item) =>
item.id === selected.id
? { ...item, login_status: result.status, login_reason: result.reason, login_checked_at: result.checked_at }
: item,
),
);
setLoginQR(null);
messageApi.success('浏览器身份核验成功。');
await load();
} catch (verifyError) {
messageApi.error(conflictMessage(verifyError, '浏览器身份核验失败;请先在指定环境人工登录'));
} finally {
setBusy(false);
}
}
async function toggleBig() {
if (!selected) return;
setBusy(true);
try {
const result = await creatorAction(`/creator/accounts/${encodeURIComponent(selected.id)}/big-account`, {
enabled: !selected.big_account,
});
setProfiles((items) => items.map((item) => (item.id === selected.id ? result : item)));
form.setFieldsValue(profileForm(result));
messageApi.success(result.big_account ? '已开启大号模式。' : '已关闭大号模式。');
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '大号模式更新失败'));
} finally {
setBusy(false);
}
}
async function createStrategy(values: any) {
if (!selected) return;
setBusy(true);
try {
const payload = {
...values,
position: Number(values.position),
candidate_texts: String(values.candidate_texts || '')
.split(',')
.map((item: string) => item.trim())
.filter(Boolean),
};
await creatorCreate('/creator/relations', {
big_account_id: selected.id,
small_account_id: values.execution_account_id,
enabled: true,
});
const result = editingStrategyID
? await creatorUpdate(`/creator/strategies/${encodeURIComponent(editingStrategyID)}`, payload)
: await creatorCreate(`/creator/accounts/${encodeURIComponent(selected.id)}/strategies`, payload);
setStrategies((items) =>
(editingStrategyID ? items.map((item) => (item.id === editingStrategyID ? result : item)) : [...items, result]).sort(
(left: any, right: any) => left.position - right.position,
),
);
strategyForm.resetFields();
setEditingStrategyID('');
messageApi.success('自动响应策略已保存。');
} catch (createError) {
messageApi.error(conflictMessage(createError, '策略保存失败;请确认执行账号已绑定且平台一致'));
} finally {
setBusy(false);
}
}
async function setStrategyEnabled(strategy: any, enabled: boolean) {
setBusy(true);
try {
const result = await creatorAction(`/creator/strategies/${encodeURIComponent(strategy.id)}/${enabled ? 'enable' : 'disable'}`);
setStrategies((items) => items.map((item) => (item.id === strategy.id ? result : item)));
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '策略状态更新失败'));
} finally {
setBusy(false);
}
}
async function unlinkRelation(relation: any) {
setBusy(true);
try {
await creatorCreate('/creator/relations', {
big_account_id: relation.big_account_id,
small_account_id: relation.small_account_id,
enabled: false,
});
setRelations((items) => items.filter((item) => item.small_account_id !== relation.small_account_id));
messageApi.success('账号关系已解除;已有策略不会自动恢复。');
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '账号关系解除失败'));
} finally {
setBusy(false);
}
}
async function deleteStrategy(strategy: any) {
setBusy(true);
try {
await creatorRequest(`/creator/strategies/${encodeURIComponent(strategy.id)}`, { method: 'DELETE' });
setStrategies((items) => items.filter((item) => item.id !== strategy.id));
messageApi.success('策略已删除。');
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '策略删除失败'));
} finally {
setBusy(false);
}
}
if (pending) return <Card loading />;
if (error && !selected)
return (
<div>
<Alert type="error" showIcon message={error.message || '账号不存在'} />
<Button style={{ marginTop: 16 }} onClick={() => history.push('/accounts')}>
</Button>
</div>
);
if (!selected) return <Alert type="warning" showIcon message="账号不存在。" />;
const smallAccounts = profiles.filter(
(profile) => profile.id !== selected.id && profile.authorization_kind === 'owned' && profile.platform === selected.platform,
);
const profileTab = (
<div>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', gap: 12 }}>
<Descriptions column={1} size="small" style={{ flex: 1, minWidth: 260 }}>
<Descriptions.Item label="登录核验">
{selected.login_status === 'logged_in' ? '已登录' : '需人工确认'}
{selected.login_checked_at ? ` · ${dateTime(selected.login_checked_at)}` : ' · 尚未核验'}
{selected.login_reason ? ` · ${selected.login_reason}` : ''}
</Descriptions.Item>
<Descriptions.Item label="密码凭据">{selected.password_configured ? '已配置' : '未配置'}</Descriptions.Item>
<Descriptions.Item label="监听">
{listener ? listener.status : '尚未启动'}
{listener?.boundary_at ? ` · 边界 ${dateTime(listener.boundary_at)}` : ' · 等待平台边界'}
{listener?.reason ? ` · ${listener.reason}` : ''}
</Descriptions.Item>
{listenerError ? <Descriptions.Item label="监听状态">{conflictMessage(listenerError, '请重试')}</Descriptions.Item> : null}
</Descriptions>
<Space wrap>
<Button disabled={busy || selected.platform !== 'douyin'} onClick={requestLoginQR}>
</Button>
<Button disabled={busy || selected.platform !== 'douyin'} onClick={verifyLogin}>
</Button>
<Button type={selected.big_account ? 'primary' : 'default'} onClick={toggleBig} disabled={busy}>
{selected.big_account ? '大号模式已开启' : '开启大号模式'}
</Button>
</Space>
</div>
{loginQR ? (
<Card size="small" title="人工登录" extra={<Button size="small" onClick={requestLoginQR} disabled={busy}></Button>}>
<Typography.Text type="secondary">
{loginQR.qr_detected ? '请使用抖音 App 扫描下方二维码。' : '当前画面未检测到二维码,请按画面提示人工完成验证。'}
</Typography.Text>
<img
style={{ marginTop: 12, maxHeight: 520, width: '100%', objectFit: 'contain', border: '1px solid #f0f0f0' }}
src={`data:${loginQR.content_type};base64,${loginQR.image_base64}`}
alt="抖音登录二维码或人工验证画面"
/>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{dateTime(loginQR.expires_at)}
</Typography.Text>
</Card>
) : null}
<Form form={form} layout="vertical" onFinish={save} initialValues={profileForm(selected)}>
<Space size={16} wrap style={{ display: 'flex' }}>
<Form.Item name="login_username" label="登录用户名" extra="密码只会进入凭据存储,不会回传到页面。">
<Input autoComplete="username" />
</Form.Item>
<Form.Item name="password" label="更新密码" extra="留空表示保持现有凭据。">
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="real_name_status" label="实名状态">
<Select
style={{ width: 140 }}
options={[
{ value: 'unknown', label: '未知' },
{ value: 'not_real_name', label: '未实名' },
{ value: 'recorded', label: '已记录' },
]}
/>
</Form.Item>
<Form.Item name="business_status" label="业务状态">
<Select
style={{ width: 120 }}
options={[
{ value: 'normal', label: '正常' },
{ value: 'muted', label: '禁言' },
{ value: 'banned', label: '封禁' },
{ value: 'deleted', label: '注销' },
]}
/>
</Form.Item>
<Form.Item name="real_name" label="实名姓名">
<Input />
</Form.Item>
<Form.Item name="identity_number" label="实名证件号">
<Input />
</Form.Item>
<Form.Item name="cooldown_seconds" label="自动响应冷却(秒)">
<InputNumber min={1} style={{ width: 160 }} />
</Form.Item>
</Space>
<Form.Item name="note" label="账号备注">
<Input.TextArea />
</Form.Item>
<Form.Item name="reply_requirements" label="统一 AI 回复要求" extra="不填写时,带文案策略必须配置候选文本。">
<Input.TextArea />
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">{selected.updated_at ? dateTime(selected.updated_at) : '—'}</Typography.Text>
<Button type="primary" htmlType="submit" loading={busy}>
{busy ? '保存中…' : '保存账号资料'}
</Button>
</div>
</Form>
</Space>
</div>
);
const strategyTab = selected.big_account ? (
<div>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Typography.Text type="secondary"></Typography.Text>
<Form form={strategyForm} layout="vertical" onFinish={createStrategy} style={{ background: '#fafafa', padding: 16, borderRadius: 8 }}>
<Space size={16} wrap style={{ display: 'flex' }}>
<Form.Item name="execution_account_id" label="执行小号" required rules={[{ required: true, message: '请先选择同平台小号' }]} extra="这里只显示自有账号;监测账号不能作为小号。">
<Select style={{ width: 220 }} placeholder="选择同平台小号" options={smallAccounts.map((p) => ({ value: p.id, label: p.name || p.platform_account_key }))} />
</Form.Item>
<Form.Item name="position" label="顺序" initialValue={1}>
<InputNumber min={1} style={{ width: 100 }} />
</Form.Item>
<Form.Item name="action" label="动作" initialValue="reply_comment">
<Select
style={{ width: 160 }}
options={[
{ value: 'reply_comment', label: '回复评论' },
{ value: 'dm', label: '私信' },
{ value: 'like_comment', label: '点赞评论' },
{ value: 'like_work', label: '点赞作品' },
{ value: 'follow', label: '关注' },
{ value: 'repost', label: '带文案转发' },
]}
/>
</Form.Item>
<Form.Item name="candidate_texts" label="候选文案" extra="逗号分隔;留空时使用大号统一 AI 要求。">
<Input style={{ width: 320 }} />
</Form.Item>
</Space>
<Form.Item name="event_types" label="触发事件" initialValue={['comment']} rules={[{ required: true, message: '至少选择一种触发事件。' }]}>
<Checkbox.Group options={eventTypeOptions} />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={busy}>
{busy ? '保存中…' : editingStrategyID ? '保存策略修改' : '绑定并保存策略'}
</Button>
{editingStrategyID ? (
<Button onClick={() => setEditingStrategyID('')} disabled={busy}>
</Button>
) : null}
</Space>
</Form>
{strategyError ? <Alert type="warning" showIcon message={`策略读取失败:${conflictMessage(strategyError, '请只读重试')}`} /> : null}
{relationError ? <Alert type="warning" showIcon message={`账号关系读取失败:${conflictMessage(relationError, '请只读重试')}`} /> : null}
{relations.map((relation) => (
<Card key={`${relation.big_account_id}:${relation.small_account_id}`} size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{relation.small_account_id}</span>
<Button size="small" onClick={() => unlinkRelation(relation)} disabled={busy}>
</Button>
</div>
</Card>
))}
{strategies.map((strategy) => (
<Card key={strategy.id} size="small">
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<Space wrap>
<span>
#{strategy.position} · {strategy.action} · {strategy.execution_account_id} · {(strategy.event_types || []).join('、')}
</span>
<Tag color={strategy.enabled ? 'success' : 'default'}>{strategy.enabled ? '启用' : '停用'}</Tag>
</Space>
<Space>
<Button
size="small"
onClick={() => {
setEditingStrategyID(strategy.id);
strategyForm.setFieldsValue({
execution_account_id: strategy.execution_account_id,
position: strategy.position,
enabled: strategy.enabled,
event_types: strategy.event_types || [],
action: strategy.action,
candidate_texts: (strategy.candidate_texts || []).join(', '),
});
}}
disabled={busy}
>
/
</Button>
<Button size="small" onClick={() => setStrategyEnabled(strategy, !strategy.enabled)} disabled={busy}>
{strategy.enabled ? '停用' : '启用'}
</Button>
<Popconfirm title="确定删除该策略吗?" onConfirm={() => deleteStrategy(strategy)}>
<Button size="small" danger disabled={busy}>
</Button>
</Popconfirm>
</Space>
</div>
</Card>
))}
{!strategies.length && !strategyError ? <Typography.Text type="secondary"></Typography.Text> : null}
</Space>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
);
return (
<div>
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={4} style={{ marginBottom: 0 }}>
{selected.name || selected.platform_account_key}
</Typography.Title>
<Typography.Text type="secondary">
{platformLabelMap[selected.platform] || selected.platform} · {selected.platform_account_key}
</Typography.Text>
</div>
<Button onClick={() => history.push('/accounts')}></Button>
</div>
<Tabs
defaultActiveKey="profile"
items={[
{ key: 'profile', label: '账号资料', children: profileTab },
{ key: 'strategy', label: '自动响应策略', children: strategyTab },
]}
/>
</div>
);
}
+260
View File
@@ -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<any>(null);
const [binding, setBinding] = useState<any>(null);
const [browsersError, setBrowsersError] = useState<any>(null);
const [drafts, setDrafts] = useState<any[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(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 <Card loading />;
if (error || !account) return <Alert type="error" showIcon message={error?.message || '账号不存在'} />;
const statusTag = (
<Tag
color={readiness?.ready ? 'success' : account.runtime_status === 'paused' && readiness?.canResume ? 'warning' : 'error'}
>
{readiness?.label}
</Tag>
);
const items = [
{
key: 'status',
label: '账号状态',
children: (
<div>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<div>{statusTag}</div>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="授权">{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}</Descriptions.Item>
<Descriptions.Item label="运行">{`${account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 ${account.version}`}</Descriptions.Item>
<Descriptions.Item label="TAGS">{account.tags?.join('、') || '无'}</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button danger disabled={actionBusy || account.runtime_status === 'paused'} onClick={() => setPauseOpen(true)}>
</Button>
<Button type="primary" disabled={actionBusy || !readiness?.canResume} loading={actionBusy} onClick={() => runAction('resume')}>
</Button>
</Space>
<Alert type="info" showIcon message="暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。" />
</Space>
</div>
),
},
{
key: 'binding',
label: '固定资源',
children: browsersError ? (
<Alert
type="error"
showIcon
message="环境不可用:运行环境与网络出口状态未知,依赖资源状态的操作暂不可用。"
action={
<Button size="small" onClick={loadAll}>
</Button>
}
/>
) : binding ? (
<div>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="运行环境">{`${binding.name}${binding.alias}`}</Descriptions.Item>
<Descriptions.Item label="网络出口">
{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}
</Descriptions.Item>
<Descriptions.Item label="绑定版本">{binding.binding_version}</Descriptions.Item>
<Descriptions.Item label="不可调度原因">
{binding.schedule_block_reason || '无'}
</Descriptions.Item>
</Descriptions>
<Typography.Link onClick={() => history.push('/browsers')} style={{ display: 'inline-block', marginTop: 12 }}>
</Typography.Link>
</div>
) : (
<div>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
<Typography.Link onClick={() => history.push('/browsers')}></Typography.Link>
</div>
),
},
{
key: 'drafts',
label: '文本草稿',
children: (
<div>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Typography.Text type="secondary"> Phase A Mock ID </Typography.Text>
<Form form={draftForm} layout="vertical">
<Form.Item
name="content"
label="草稿内容"
extra={readiness?.ready ? '创建后进入只读快照核对并显式确认' : `资源未就绪:${readiness?.label || '正在加载'}`}
>
<Input.TextArea rows={4} disabled={!readiness?.ready || draftBusy} />
</Form.Item>
<Button
type="primary"
loading={draftBusy}
disabled={!readiness?.ready}
onClick={onCreateDraft}
>
稿
</Button>
</Form>
<Typography.Text type="secondary">稿稿</Typography.Text>
{drafts.map((draft) => (
<Card key={draft.id} size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ minWidth: 0 }}>
<Typography.Text strong>稿 {draft.version}</Typography.Text>
<Typography.Paragraph type="secondary" ellipsis style={{ marginBottom: 0, fontSize: 12 }}>
{draft.content}
</Typography.Paragraph>
</div>
<Typography.Link onClick={() => history.push(`/drafts/${draft.id}`)} style={{ flexShrink: 0 }}>
稿
</Typography.Link>
</div>
</Card>
))}
</Space>
</div>
),
},
];
return (
<div>
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ minWidth: 0 }}>
<Typography.Title level={4} style={{ marginBottom: 4 }} ellipsis>
{account.name}
</Typography.Title>
<Typography.Text type="secondary">
{account.platform_account_key} · {account.platform}
</Typography.Text>
</div>
<Space>
<Button type="primary" onClick={() => history.push(`/accounts/${account.id}/edit`)}>
</Button>
<Button onClick={() => history.push('/accounts')}></Button>
</Space>
</div>
<Tabs defaultActiveKey="status" items={items} />
<Modal
open={pauseOpen}
title="暂停账号"
onCancel={() => setPauseOpen(false)}
onOk={() => {
setPauseOpen(false);
runAction('pause');
}}
okText="确认暂停"
okButtonProps={{ danger: true }}
>
<p>{`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`}</p>
</Modal>
</div>
);
}
+209 -2
View File
@@ -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<Job[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm<FormValues>();
const [busy, setBusy] = useState(false);
const [retryID, setRetryID] = useState<string | null>(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<string, unknown> = {
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<Job> = [
{
title: '分享链接',
dataIndex: 'share_url',
width: 280,
render: (_, job) => (
<div>
<Typography.Link href={job.share_url} target="_blank" ellipsis style={{ maxWidth: 280, display: 'block' }}>
{job.share_url}
</Typography.Link>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{dateTime(job.created_at)}</Typography.Text>
</div>
),
},
{ 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 <Tag color={status.tone === 'error' ? 'error' : status.tone === 'success' ? 'success' : status.tone === 'warning' ? 'warning' : 'default'}>{status.label}</Tag>;
},
},
{ title: '尝试次数', dataIndex: 'attempts', render: (value?: number) => <Typography.Text type="secondary">{value ?? 0} / 3</Typography.Text> },
{
title: '失败原因',
dataIndex: 'failure_reason',
render: (value?: string) => (value ? <Typography.Text type="danger" style={{ fontSize: 12 }}>{value}</Typography.Text> : '—'),
},
{
title: '操作',
key: 'actions',
align: 'right',
render: (_, job) =>
job.status === 'failed' ? (
<Button size="small" disabled={retryID === job.id} onClick={() => retryShareJob(job.id)}>
{retryID === job.id ? '入队中…' : '重新入队'}
</Button>
) : (
'—'
),
},
];
return (
<div>
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCreateOpen(true);
}}
>
</Button>
</div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Typography.Title level={5} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={loadShareJobs} disabled={pending}>
</Button>
</div>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={loadShareJobs}></Button>} />
) : null}
<Table rowKey="id" columns={columns} dataSource={jobs} loading={pending} pagination={false} locale={{ emptyText: '暂无分享链接解析任务。' }} />
<Modal open={createOpen} title="创建账号导入任务" onCancel={closeCreate} onOk={createJob} confirmLoading={busy} okText={busy ? '加入中…' : '加入解析队列'} width={560}>
<Form form={form} layout="vertical">
<Form.Item name="shareText" label="分享内容或链接" required extra="可直接粘贴平台分享文案,系统会自动提取其中的链接并识别作者。">
<Input.TextArea rows={4} placeholder="粘贴抖音或小红书的分享内容" />
</Form.Item>
{shareText ? (
extractedShareURL ? (
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
{extractedShareURL}
{sharePlatform ? ` · ${sharePlatform === 'douyin' ? '抖音' : '小红书'}` : ' · 待识别平台'}
</Typography.Paragraph>
) : (
<Typography.Paragraph type="danger" style={{ fontSize: 12 }}> http(s) </Typography.Paragraph>
)
) : null}
<Form.Item name="tags" label="TAGS" extra="回车或逗号确认,最多 20 个标签,每个最多 32 个字符。">
<Select mode="tags" tokenSeparators={[',', '']} placeholder="如:重点监控" open={false} />
</Form.Item>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
3
</Typography.Text>
</Form>
</Modal>
</div>
);
}
+4 -2
View File
@@ -1,4 +1,6 @@
// TODO: 从 web.archived 迁移 accounts/index
// 我的账号列表:语义对齐 web.archived AccountManagementList(mode="owned")。
import AccountManagementList from '@/components/accounts/AccountManagementList';
export default function Page() {
return null;
return <AccountManagementList mode="owned" />;
}
+4 -2
View File
@@ -1,4 +1,6 @@
// TODO: 从 web.archived 迁移 accounts/monitoring
// 监控账号列表:语义对齐 web.archived AccountManagementList(mode="monitoring")。
import AccountManagementList from '@/components/accounts/AccountManagementList';
export default function Page() {
return null;
return <AccountManagementList mode="monitoring" />;
}
+77 -2
View File
@@ -1,4 +1,79 @@
// TODO: 从 web.archived 迁移 accounts/new
// 创建社媒账号:语义对齐 web.archived AccountCreatePage + AccountCreateForm。
// cookies 非必填:留空代表创建后走扫码登录。创建成功跳编辑页。
import { useState } from 'react';
import { history } from '@umijs/max';
import { Alert, Button, Card, Form, Input, Select } from 'antd';
import { create } from '@/services/api';
import { conflictMessage, platforms } from '@/utils/helpers';
interface FormValues {
name: string;
platform: string;
platform_account_key: string;
tags?: string[];
cookies?: string;
}
export default function Page() {
return null;
const [form] = Form.useForm<FormValues>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<any>(null);
async function onFinish(values: FormValues) {
setBusy(true);
setError(null);
try {
const data: Record<string, unknown> = {
name: values.name.trim(),
platform: values.platform.trim(),
platform_account_key: values.platform_account_key.trim(),
tags: values.tags ?? [],
};
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
if (values.cookies?.trim()) data.cookies = values.cookies.trim();
const result = await create('accounts', data);
const id = result?.data?.id ?? result?.id;
if (!id) throw new Error('创建账号未返回账号 ID');
history.replace(`/accounts/${encodeURIComponent(id)}/edit`);
} catch (createError) {
setError(createError);
} finally {
setBusy(false);
}
}
return (
<Card title="创建社媒账号">
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={conflictMessage(error, '该平台的账号 ID 已存在;表单内容已保留。')} />
) : null}
<Form form={form} layout="vertical" onFinish={onFinish} style={{ maxWidth: 720 }}>
<Form.Item name="name" label="账号名称" required rules={[{ required: true, message: '请输入账号名称' }]} extra="展示名称,支持中文">
<Input maxLength={128} placeholder="如:店铺一号" />
</Form.Item>
<Form.Item name="platform" label="平台类型" required rules={[{ required: true, message: '请选择平台' }]} extra="账号所属平台">
<Select placeholder="选择平台" options={platforms} />
</Form.Item>
<Form.Item name="platform_account_key" label="账号 ID" required rules={[{ required: true, message: '请输入账号 ID' }]} extra="平台内唯一标识,同平台不可重复">
<Input maxLength={128} />
</Form.Item>
<Form.Item name="tags" label="TAGS" extra="回车或逗号确认,退格删除末位标签">
<Select mode="tags" tokenSeparators={[',', '']} placeholder="如:主账号" open={false} />
</Form.Item>
<Form.Item
name="cookies"
label="Cookies"
extra="可选。留空则创建后扫码登录;仅支持浏览器 Cookie Header 格式,如 name=value; token=value"
>
<Input.TextArea maxLength={8192} rows={3} placeholder="可选,如:sessionid=value; token=value" />
</Form.Item>
<Form.Item>
<Button onClick={() => history.push('/accounts')}></Button>
<Button type="primary" htmlType="submit" loading={busy} style={{ marginLeft: 8 }}>
{busy ? '创建中…' : '创建账号'}
</Button>
</Form.Item>
</Form>
</Card>
);
}
-4
View File
@@ -1,4 +0,0 @@
// TODO: 从 web.archived 迁移 gateways
export default function Page() {
return null;
}
+4
View File
@@ -0,0 +1,4 @@
// TODO: task-5 迁移网关管理页(web.archived/src/features/environments/GatewaysPage.jsx
export default function Page() {
return null;
}
-57
View File
@@ -1,57 +0,0 @@
import { history } from '@umijs/max';
import { Alert, Button, Card, Form, Input } from 'antd';
import { useState } from 'react';
import { request } from '@/requestErrorConfig';
// 语义对齐 web.archived/src/app/LoginPage.jsx
// 提交 GET /api/gateways 校验 Basic 凭证,成功后写入 localStorage('creatorhub.auth')。
export default function LoginPage() {
const [form] = Form.useForm<{ username: string; password: string }>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function onFinish({ username, password }: { username: string; password: string }) {
setBusy(true);
setError('');
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch('/api/gateways', {
headers: { Authorization: `Basic ${btoa(`${username}:${password}`)}` },
signal: controller.signal,
});
if (response.status === 401) throw new Error('用户名或密码不正确');
if (!response.ok) throw new Error(`认证服务不可用(${response.status}`);
localStorage.setItem('creatorhub.auth', `${username}:${password}`);
history.replace('/');
} catch (reason: any) {
setError(
reason.name === 'AbortError'
? '认证请求超时,请检查控制面连接'
: reason.message || '登录失败',
);
} finally {
window.clearTimeout(timer);
setBusy(false);
}
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Card title="CreatorHub · 登录" style={{ width: 360 }}>
{error ? <Alert type="error" message={error} style={{ marginBottom: 16 }} /> : null}
<Form form={form} layout="vertical" onFinish={onFinish}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input autoComplete="username" />
</Form.Item>
<Form.Item label="密码" name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={busy}>
{busy ? '登录中…' : '登录'}
</Button>
</Form>
</Card>
</div>
);
}
+109
View File
@@ -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<string, { label: string; tone: 'success' | 'warning' | 'error' | 'default' }> = {
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<string, string> = {
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'
);
}
+25
View File
@@ -0,0 +1,25 @@
// 列表页共享工具:资源 → 查询字段白名单,对齐 web.archived/src/shared/api/dataProvider.js 的 filterKeys。
// creator-* 资源走 page/page_size 分页;其余资源后端返回全量或自带 total。
export const filterKeys: Record<string, string[]> = {
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;
}
+60
View File
@@ -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: '运营控制台' };
}