feat(web): task-3 accounts 模块全量迁移(列表/监控/导入/新建/详情/编辑,antd 默认组件 + 约定式路由 + terser 压缩绕开 esbuild helper 冲突)
This commit is contained in:
+3
-31
@@ -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',
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// TODO: 从 web.archived 迁移 accounts/$id
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// TODO: 从 web.archived 迁移 gateways
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// TODO: task-5 迁移网关管理页(web.archived/src/features/environments/GatewaysPage.jsx)
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '运营控制台' };
|
||||
}
|
||||
Reference in New Issue
Block a user