Merge branch 'feature/douyin-only-monitoring'——平台收敛为抖音、监控账号画像字段与卡片列表
douyin-release-gate / verify (push) Failing after 3m38s
douyin-release-gate / verify (push) Failing after 3m38s
# Conflicts: # internal/creator/content.go
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
// 我的账号列表(owned):语义对齐 web.archived AccountManagementList(mode="owned")。
|
||||
// 监控账号列表已拆分至 components/accounts/MonitoringAccountList.tsx(card list)。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import {
|
||||
@@ -6,7 +8,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
@@ -16,12 +17,12 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined } 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';
|
||||
import { getList, remove, creatorUpdate } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
import { usePageActions } from '@/components/PageActions';
|
||||
|
||||
// 语义对齐 web.archived/src/features/accounts/AccountsPage.jsx 的 AccountManagementList(mode: owned/monitoring)。
|
||||
// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。
|
||||
interface Row {
|
||||
id: string;
|
||||
@@ -29,15 +30,11 @@ interface Row {
|
||||
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';
|
||||
export default function AccountManagementList() {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
@@ -51,13 +48,11 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resource = isMonitoring ? 'creator-competitors' : 'accounts';
|
||||
const result = await getList({ resource: resource as any });
|
||||
const result = await getList({ resource: 'accounts' });
|
||||
setRows(
|
||||
(result.data ?? []).map((account: any) => ({
|
||||
...account,
|
||||
name: isMonitoring ? account.nickname || account.platform_account_key : account.name,
|
||||
source_type: isMonitoring ? 'monitoring' : 'owned',
|
||||
name: account.name,
|
||||
tags: account.tags ?? [],
|
||||
})),
|
||||
);
|
||||
@@ -66,56 +61,20 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
|
||||
} 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}`);
|
||||
setActionBusy(row.id);
|
||||
try {
|
||||
await remove(row.source_type === 'owned' ? 'accounts' : 'creator-competitors', row.id);
|
||||
messageApi.success(`${label}已删除。`);
|
||||
await remove('accounts', row.id);
|
||||
messageApi.success('自有账号已删除。');
|
||||
await load();
|
||||
} catch (deleteError) {
|
||||
messageApi.error(conflictMessage(deleteError, `${label}删除失败`));
|
||||
messageApi.error(conflictMessage(deleteError, '自有账号删除失败'));
|
||||
} finally {
|
||||
setActionBusy('');
|
||||
}
|
||||
@@ -131,9 +90,7 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
|
||||
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 }));
|
||||
await creatorUpdate(`/creator/accounts/${encodeURIComponent(tagTarget.id)}/tags`, { tags });
|
||||
setRows((current) => current.map((row) => (row.id === tagTarget.id ? { ...row, tags } : row)));
|
||||
setTagTarget(null);
|
||||
messageApi.success('标签已保存。');
|
||||
@@ -150,18 +107,14 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
|
||||
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.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name}</Typography.Link>
|
||||
<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: 'platform', render: () => '抖音' },
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tags',
|
||||
@@ -170,17 +123,14 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (_, account) =>
|
||||
account.source_type === 'owned' ? (
|
||||
<Flex>
|
||||
<Tag color={account.runtime_status === 'active' ? 'success' : 'default'}>
|
||||
{account.runtime_status === 'active' ? '启用' : '暂停'}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary">{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}</Typography.Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Tag color={account.enabled ? 'success' : 'default'}>{account.enabled ? '监控中' : '已暂停'}</Tag>
|
||||
),
|
||||
render: (_, account) => (
|
||||
<Flex>
|
||||
<Tag color={account.runtime_status === 'active' ? 'success' : 'default'}>
|
||||
{account.runtime_status === 'active' ? '启用' : '暂停'}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary">{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -191,31 +141,18 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<Button size="small" onClick={() => history.push(`/accounts/${account.id}/edit`)}>
|
||||
编辑账号
|
||||
</Button>
|
||||
<Button size="small" onClick={() => history.push(`/accounts/${account.id}`)}>
|
||||
查看账号
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除${account.source_type === 'owned' ? '自有账号' : '监测账号'}“${account.name}”吗?相关专属资源也会被清理。`}
|
||||
title={`确定删除自有账号“${account.name}”吗?相关专属资源也会被清理。`}
|
||||
onConfirm={() => deleteAccount(account)}
|
||||
disabled={actionBusy === `${account.source_type}:${account.id}`}
|
||||
disabled={actionBusy === account.id}
|
||||
>
|
||||
<Button size="small" danger disabled={actionBusy === `${account.source_type}:${account.id}`}>
|
||||
<Button size="small" danger disabled={actionBusy === account.id}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -224,35 +161,28 @@ export default function AccountManagementList({ mode }: { mode: 'owned' | 'monit
|
||||
},
|
||||
];
|
||||
|
||||
const primaryAction = isMonitoring ? (
|
||||
<Button type="primary" icon={<CloudUploadOutlined />} onClick={() => history.push('/accounts/import')}>
|
||||
导入竞品账号
|
||||
</Button>
|
||||
) : (
|
||||
usePageActions(
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/accounts/new')}>
|
||||
创建账号
|
||||
</Button>
|
||||
</Button>,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
{contextHolder}
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
共 {rows.length} 个{isMonitoring ? '监控' : '自有'}账号
|
||||
</Typography.Text>
|
||||
{primaryAction}
|
||||
<Typography.Text type="secondary">共 {rows.length} 个自有账号</Typography.Text>
|
||||
</Flex>
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Table
|
||||
rowKey={(row) => `${row.source_type}:${row.id}`}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
loading={pending}
|
||||
pagination={false}
|
||||
locale={{ emptyText: isMonitoring ? '暂无监控账号。' : '暂无自有账号。' }}
|
||||
locale={{ emptyText: '暂无自有账号。' }}
|
||||
/>
|
||||
<Modal
|
||||
open={Boolean(tagTarget)}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// 监控账号列表(monitoring):一行一个账号的 card list,对齐 pro table-list 的卡片列表形态。
|
||||
// 展示作者头像与粉丝/关注/作品/最近发布等账号画像属性(migration 038 字段 + work 聚合)。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Flex,
|
||||
Form,
|
||||
Listy,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { getList, remove, creatorAction, creatorUpdate } from '@/services/api';
|
||||
import { conflictMessage, dateTime, isCollectionAccount, platformLabel } from '@/utils/helpers';
|
||||
|
||||
interface CompetitorRow {
|
||||
id: string;
|
||||
platform: string;
|
||||
platform_account_key: string;
|
||||
unique_id?: string;
|
||||
nickname: string;
|
||||
avatar_url: string;
|
||||
homepage_url?: string;
|
||||
tags?: string[];
|
||||
enabled: boolean;
|
||||
follower_count?: number | null;
|
||||
following_count?: number | null;
|
||||
aweme_count?: number | null;
|
||||
sync_status?: string;
|
||||
sync_error?: string;
|
||||
last_sync_at?: string | null;
|
||||
work_count?: number;
|
||||
latest_published_at?: string | null;
|
||||
}
|
||||
|
||||
const syncStatusMeta: Record<string, { color?: string; label: string }> = {
|
||||
idle: { color: 'success', label: '监控中' },
|
||||
running: { color: 'processing', label: '同步中' },
|
||||
paused: { label: '已暂停' },
|
||||
failed: { color: 'error', label: '同步失败' },
|
||||
blocked: { color: 'warning', label: '同步受阻' },
|
||||
};
|
||||
|
||||
function formatCount(value?: number | null): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : `${value}`;
|
||||
}
|
||||
|
||||
export default function MonitoringAccountList() {
|
||||
const [rows, setRows] = useState<CompetitorRow[]>([]);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [tagTarget, setTagTarget] = useState<CompetitorRow | 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 result = await getList({ resource: 'creator-competitors' });
|
||||
setRows(result.data ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function syncCompetitor(competitor: CompetitorRow) {
|
||||
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: CompetitorRow) {
|
||||
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: CompetitorRow) {
|
||||
setActionBusy(row.id);
|
||||
try {
|
||||
await remove('creator-competitors', row.id);
|
||||
messageApi.success('监测账号已删除。');
|
||||
await load();
|
||||
} catch (deleteError) {
|
||||
messageApi.error(conflictMessage(deleteError, '监测账号删除失败'));
|
||||
} finally {
|
||||
setActionBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
function openTags(row: CompetitorRow) {
|
||||
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 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 syncMeta = syncStatusMeta[tagTarget?.sync_status ?? ''] || { label: tagTarget?.sync_status || '—' };
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
{contextHolder}
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {rows.length} 个监控账号</Typography.Text>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} disabled={pending} aria-label="刷新" />
|
||||
<Button type="primary" icon={<CloudUploadOutlined />} onClick={() => history.push('/accounts/import')}>
|
||||
导入竞品账号
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Listy
|
||||
rowKey={(row) => row.id}
|
||||
items={rows}
|
||||
itemRender={(row) => {
|
||||
const meta = syncStatusMeta[row.sync_status ?? ''] || { label: row.sync_status || '—' };
|
||||
return (
|
||||
<div style={{ padding: '16px 0', borderBottom: '1px solid rgba(5,5,5,0.06)' }}>
|
||||
<Flex justify="space-between" align="flex-start" gap={16}>
|
||||
<Flex align="flex-start" gap={12}>
|
||||
<Avatar size={44} src={row.avatar_url || undefined}>
|
||||
{(row.nickname || row.platform_account_key).slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Space wrap size={8}>
|
||||
<Typography.Text strong>{row.nickname || row.platform_account_key}</Typography.Text>
|
||||
<Tag>{platformLabel(row.platform)}</Tag>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
{(row.tags ?? []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }} ellipsis>
|
||||
{row.platform_account_key}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</Flex>
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={() => openTags(row)}>
|
||||
编辑标签
|
||||
</Button>
|
||||
<Button size="small" disabled={actionBusy === row.id} onClick={() => syncCompetitor(row)}>
|
||||
同步
|
||||
</Button>
|
||||
<Button size="small" disabled={actionBusy === row.id} onClick={() => toggleCompetitor(row)}>
|
||||
{row.enabled ? '暂停' : '恢复'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除监测账号“${row.nickname || row.platform_account_key}”吗?相关专属资源也会被清理。`}
|
||||
onConfirm={() => deleteAccount(row)}
|
||||
disabled={actionBusy === row.id}
|
||||
>
|
||||
<Button size="small" danger disabled={actionBusy === row.id}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Flex>
|
||||
<Descriptions
|
||||
style={{ marginTop: 12 }}
|
||||
size="small"
|
||||
column={{ xs: 2, sm: 3, md: 6 }}
|
||||
items={[
|
||||
{ key: 'follower', label: '粉丝', children: formatCount(row.follower_count) },
|
||||
{ key: 'following', label: '关注', children: formatCount(row.following_count) },
|
||||
{ key: 'works', label: '作品', children: formatCount(row.work_count ?? row.aweme_count ?? null) },
|
||||
{ key: 'published', label: '最近发布', children: row.latest_published_at ? dateTime(row.latest_published_at) : '—' },
|
||||
{ key: 'lastSync', label: '上次同步', children: row.last_sync_at ? dateTime(row.last_sync_at) : '—' },
|
||||
{
|
||||
key: 'syncError',
|
||||
label: '同步异常',
|
||||
children: row.sync_error ? (
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>{row.sync_error}</Typography.Text>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{pending ? (
|
||||
<div style={{ textAlign: 'center', padding: '32px 0' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : null}
|
||||
{!pending && !error && rows.length === 0 ? <Empty description="暂无监控账号。" /> : null}
|
||||
<Modal
|
||||
open={Boolean(tagTarget)}
|
||||
title={`编辑标签 · ${tagTarget?.nickname || tagTarget?.platform_account_key || '账号'}`}
|
||||
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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { creatorAction, creatorCreate, creatorGet, creatorRequest, creatorUpdate, getList } from '@/services/api';
|
||||
import { conflictMessage, dateTime } from '@/utils/helpers';
|
||||
|
||||
const platformLabelMap: Record<string, string> = { douyin: '抖音', xiaohongshu: '小红书' };
|
||||
const platformLabelMap: Record<string, string> = { douyin: '抖音' };
|
||||
const eventTypeOptions = [
|
||||
{ value: 'comment', label: '评论' },
|
||||
{ value: 'like', label: '点赞' },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
@@ -29,6 +30,7 @@ interface Job {
|
||||
failure_reason?: string;
|
||||
created_at?: string;
|
||||
author_name?: string;
|
||||
author_avatar_url?: string;
|
||||
competitor_id?: string;
|
||||
}
|
||||
|
||||
@@ -128,8 +130,15 @@ export default function Page() {
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
// 名称来自 competitor_id 关联的监控账号(creator_competitor),点击跳转竞品作者详情。
|
||||
const inner = <Typography.Text strong>{value}</Typography.Text>;
|
||||
// 名称/头像来自 competitor_id 关联的监控账号(creator_competitor),点击跳转竞品作者详情。
|
||||
const inner = (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar size={24} src={job?.author_avatar_url || undefined}>
|
||||
{value.slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<Typography.Text strong>{value}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
return job?.competitor_id ? (
|
||||
<Typography.Link href={`/creator/competitors/${job.competitor_id}`}>{inner}</Typography.Link>
|
||||
) : (
|
||||
@@ -199,13 +208,13 @@ export default function Page() {
|
||||
<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="粘贴抖音或小红书的分享内容" />
|
||||
<Input.TextArea rows={4} placeholder="粘贴抖音的分享内容" />
|
||||
</Form.Item>
|
||||
{shareText ? (
|
||||
extractedShareURL ? (
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||
已提取链接:{extractedShareURL}
|
||||
{sharePlatform ? ` · ${sharePlatform === 'douyin' ? '抖音' : '小红书'}` : ' · 待识别平台'}
|
||||
{sharePlatform ? ' · 抖音' : ' · 待识别平台'}
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<Typography.Paragraph type="danger" style={{ fontSize: 12 }}>未找到可用的 http(s) 分享链接。</Typography.Paragraph>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 我的账号列表:语义对齐 web.archived AccountManagementList(mode="owned")。
|
||||
// 我的账号列表(owned):语义对齐 web.archived AccountManagementList(mode="owned")。
|
||||
import AccountManagementList from '@/components/accounts/AccountManagementList';
|
||||
|
||||
export default function Page() {
|
||||
return <AccountManagementList mode="owned" />;
|
||||
return <AccountManagementList />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 监控账号列表:语义对齐 web.archived AccountManagementList(mode="monitoring")。
|
||||
import AccountManagementList from '@/components/accounts/AccountManagementList';
|
||||
// 监控账号列表:一行一个账号的 card list(含头像与粉丝/关注/作品/最近发布画像)。
|
||||
import MonitoringAccountList from '@/components/accounts/MonitoringAccountList';
|
||||
|
||||
export default function Page() {
|
||||
return <AccountManagementList mode="monitoring" />;
|
||||
return <MonitoringAccountList />;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
// 竞品分析:语义对齐 web.archived/src/features/creator/competitors/CreatorCompetitorsPage.jsx(提交 d97cade)。
|
||||
// 仅用 antd 默认组件;筛选自动刷新、手动上一页/下一页(后端 has_next)、素材三步处理与仿写流程原样保留。
|
||||
// 竞品分析:作品列表对齐 ant-design-pro card-list——antd Row/Col 响应式栅格 + Card 原样组合;
|
||||
// 作品详情改走 Drawer;素材三步处理与仿写流程内联卡片原样保留;筛选自动刷新、手动翻页不变。
|
||||
// 平台筛选已随 migration 038(platform 收敛 douyin)移除。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Flex, Alert, Button, Card, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CommentOutlined, LikeOutlined, ShareAltOutlined } from '@ant-design/icons';
|
||||
import { Flex, Alert, Button, Card, Col, Drawer, Empty, Input, InputNumber, Modal, Row, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
||||
import { creatorAction, creatorGet, creatorUpdate, getList } from '@/services/api';
|
||||
import { conflictMessage, dateTime } from '@/utils/helpers';
|
||||
|
||||
const platformOptions = [
|
||||
{ value: 'douyin', label: '抖音' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
];
|
||||
|
||||
const publishedAtStatusOptions = [
|
||||
{ value: 'verified', label: '已核验' },
|
||||
{ value: 'future', label: '未来时间' },
|
||||
{ value: 'unverified', label: '待核验' },
|
||||
{ value: 'pending_verification', label: '待核验' },
|
||||
{ value: 'invalid', label: '无效时间' },
|
||||
];
|
||||
|
||||
const publishedAtStatusMeta: Record<string, { color?: string; label: string }> = {
|
||||
verified: { color: 'green', label: '已核验' },
|
||||
future: { color: 'orange', label: '未来时间' },
|
||||
pending_verification: { label: '待核验' },
|
||||
invalid: { color: 'red', label: '无效时间' },
|
||||
};
|
||||
|
||||
interface Filters {
|
||||
platform?: string;
|
||||
minLikes?: number;
|
||||
minComments?: number;
|
||||
minShares?: number;
|
||||
@@ -28,8 +30,13 @@ interface Filters {
|
||||
publishedBefore?: string;
|
||||
}
|
||||
|
||||
function statusTag(tone: 'success' | 'error' | 'default', label: string) {
|
||||
return <Tag color={tone === 'default' ? undefined : tone}>{label}</Tag>;
|
||||
function statusTag(status?: string) {
|
||||
const meta = publishedAtStatusMeta[status || ''] || { label: status || '—' };
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>;
|
||||
}
|
||||
|
||||
function stepTag(status: string, label: string) {
|
||||
return <Tag color={status === 'succeeded' ? 'success' : status === 'failed' ? 'error' : undefined}>{label}</Tag>;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
@@ -59,7 +66,6 @@ export default function Page() {
|
||||
page: workPage,
|
||||
pageSize: 25,
|
||||
filters: {
|
||||
platform: filters.platform,
|
||||
min_likes: filters.minLikes === undefined ? undefined : Number(filters.minLikes),
|
||||
min_comments: filters.minComments === undefined ? undefined : Number(filters.minComments),
|
||||
min_shares: filters.minShares === undefined ? undefined : Number(filters.minShares),
|
||||
@@ -201,60 +207,6 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{
|
||||
title: '作品',
|
||||
dataIndex: 'title',
|
||||
width: 280,
|
||||
render: (_, work) => (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Button type="link" size="small" style={{ padding: 0, height: 'auto' }} onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>
|
||||
<Typography.Text strong ellipsis style={{ maxWidth: 260 }}>
|
||||
{work.title || '无标题'}
|
||||
</Typography.Text>
|
||||
</Button>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }} ellipsis>
|
||||
{work.author_name || work.source_id} · {work.published_at_status}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '发布时间',
|
||||
dataIndex: 'published_at',
|
||||
render: (value?: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{value ? dateTime(value) : '待核验'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: '点赞 / 评论 / 转发', key: 'metrics', render: (_, work) => <Typography.Text style={{ fontSize: 12 }}>{work.likes ?? '—'} / {work.comments_count ?? '—'} / {work.shares ?? '—'}</Typography.Text> },
|
||||
{
|
||||
title: '指标下次采集',
|
||||
dataIndex: 'next_metric_at',
|
||||
render: (value?: string, work?: any) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{value ? dateTime(value) : work?.metric_stop_reason || '—'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
render: (_, work) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>
|
||||
详情
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => selectMaterial(work.id)}>
|
||||
选择素材
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const materialReadyForConfirm =
|
||||
material &&
|
||||
material.download_status === 'succeeded' &&
|
||||
@@ -262,19 +214,11 @@ export default function Page() {
|
||||
!['not_started', 'running', 'failed'].includes(material.transcription_status);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex>
|
||||
<Card title="筛选作品" extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整条件后自动刷新</Typography.Text>}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card title="筛选作品" extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整条件后自动刷新</Typography.Text>}>
|
||||
<Space size={12} wrap style={{ display: 'flex' }}>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部平台"
|
||||
allowClear
|
||||
value={filters.platform}
|
||||
onChange={(value) => setFilters((f) => ({ ...f, platform: value }))}
|
||||
options={platformOptions}
|
||||
/>
|
||||
<InputNumber style={{ width: 130 }} placeholder="最低点赞" min={0} value={filters.minLikes} onChange={(value) => setFilters((f) => ({ ...f, minLikes: value ?? undefined }))} />
|
||||
<InputNumber style={{ width: 130 }} placeholder="最低评论" min={0} value={filters.minComments} onChange={(value) => setFilters((f) => ({ ...f, minComments: value ?? undefined }))} />
|
||||
<InputNumber style={{ width: 130 }} placeholder="最低转发" min={0} value={filters.minShares} onChange={(value) => setFilters((f) => ({ ...f, minShares: value ?? undefined }))} />
|
||||
@@ -289,72 +233,102 @@ export default function Page() {
|
||||
<Input type="date" style={{ width: 160 }} value={filters.publishedAfter} onChange={(e) => setFilters((f) => ({ ...f, publishedAfter: e.target.value }))} />
|
||||
<Input type="date" style={{ width: 160 }} value={filters.publishedBefore} onChange={(e) => setFilters((f) => ({ ...f, publishedBefore: e.target.value }))} />
|
||||
</Space>
|
||||
</Card>
|
||||
<Card title="作品与指标">
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Table rowKey="id" columns={columns} dataSource={works} loading={pending} pagination={false} locale={{ emptyText: '暂无符合条件的作品。' }} />
|
||||
{workPageInfo.total > works.length ? (
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => Math.max(1, page - 1))} disabled={workPage === 1 || pending}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => page + 1)} disabled={!workPageInfo.hasNext || pending}>
|
||||
下一页
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
{workDetail ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={<span>作品详情 <Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>{workDetail.work_key || workDetail.id}</Typography.Text></span>}
|
||||
extra={<Button size="small" onClick={() => setWorkDetail(null)}>关闭</Button>}
|
||||
>
|
||||
<Flex>
|
||||
{workDetail.cover_url ? <img src={workDetail.cover_url} alt="作品封面" style={{ maxHeight: 256, borderRadius: 4, border: '1px solid #f0f0f0' }} /> : null}
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{workDetail.body || workDetail.title || '暂无正文'}</Typography.Paragraph>
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">来源:{workDetail.source_type || '—'}</Typography.Text>
|
||||
<Typography.Text type="secondary">状态:{workDetail.published_at_status || '—'}</Typography.Text>
|
||||
{workDetail.original_url ? (
|
||||
<Typography.Link href={workDetail.original_url} target="_blank">
|
||||
打开原文
|
||||
</Typography.Link>
|
||||
) : null}
|
||||
</Card>
|
||||
<Card title="作品与指标">
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {workPageInfo.total} 个作品</Typography.Text>
|
||||
</Flex>
|
||||
<Spin spinning={pending}>
|
||||
{works.length ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{works.map((work) => (
|
||||
<Col key={work.id} xs={24} sm={12} lg={8} xl={6}>
|
||||
<Card
|
||||
style={{ height: '100%' }}
|
||||
cover={
|
||||
work.cover_url ? (
|
||||
<img src={work.cover_url} alt={work.title || '作品封面'} style={{ width: '100%', aspectRatio: '16 / 9', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Flex align="center" justify="center" style={{ aspectRatio: '16 / 9', background: '#f5f5f5' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>无封面</Typography.Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<Button key="detail" type="link" size="small" onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>
|
||||
详情
|
||||
</Button>,
|
||||
<Button key="select" type="primary" size="small" onClick={() => selectMaterial(work.id)}>
|
||||
选择素材
|
||||
</Button>,
|
||||
...(work.original_url ? [<Typography.Link key="origin" href={work.original_url} target="_blank">原文</Typography.Link>] : []),
|
||||
]}
|
||||
>
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
<Typography.Paragraph strong ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
|
||||
{work.title || '无标题'}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{work.author_name || work.source_id}
|
||||
</Typography.Text>
|
||||
<Space size={8} wrap>
|
||||
{statusTag(work.published_at_status)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{work.published_at ? dateTime(work.published_at) : '—'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Flex gap={16}>
|
||||
<Typography.Text style={{ fontSize: 12 }}><LikeOutlined /> {work.likes ?? '—'}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12 }}><CommentOutlined /> {work.comments_count ?? '—'}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12 }}><ShareAltOutlined /> {work.shares ?? '—'}</Typography.Text>
|
||||
</Flex>
|
||||
{work.next_metric_at || work.metric_stop_reason ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{work.next_metric_at ? `下次采集 ${dateTime(work.next_metric_at)}` : work.metric_stop_reason}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : (
|
||||
<Empty description="暂无符合条件的作品。" />
|
||||
)}
|
||||
</Spin>
|
||||
{workPageInfo.total > works.length ? (
|
||||
<Flex justify="flex-end" style={{ marginTop: 12 }}>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => Math.max(1, page - 1))} disabled={workPage === 1 || pending}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => page + 1)} disabled={!workPageInfo.hasNext || pending}>
|
||||
下一页
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="collected_at"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={workMetrics}
|
||||
columns={[
|
||||
{ title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
|
||||
{ title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
|
||||
{ title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
|
||||
{ title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
|
||||
]}
|
||||
locale={{ emptyText: '暂无指标记录。' }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
) : null}
|
||||
) : null}
|
||||
</Card>
|
||||
{material ? (
|
||||
<Card size="small" title="素材处理">
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>每一步都保留真实状态;失败不会被显示为成功。</Typography.Paragraph>
|
||||
<Button type="primary" onClick={processMaterial} loading={materialPending || material.download_status === 'running'} style={{ marginBottom: 16 }}>
|
||||
开始处理素材
|
||||
</Button>
|
||||
<Flex>
|
||||
<Flex vertical gap={12}>
|
||||
<div>
|
||||
<Button type="primary" onClick={processMaterial} loading={materialPending || material.download_status === 'running'}>
|
||||
开始处理素材
|
||||
</Button>
|
||||
</div>
|
||||
<Space wrap>
|
||||
{statusTag(material.download_status === 'succeeded' ? 'success' : material.download_status === 'failed' ? 'error' : 'default', `下载:${material.download_status}`)}
|
||||
{statusTag(material.audio_status === 'succeeded' ? 'success' : material.audio_status === 'failed' ? 'error' : 'default', `音轨:${material.audio_status}`)}
|
||||
{statusTag(material.transcription_status === 'succeeded' ? 'success' : material.transcription_status === 'failed' ? 'error' : 'default', `转写:${material.transcription_status}`)}
|
||||
{stepTag(material.download_status, `下载:${material.download_status}`)}
|
||||
{stepTag(material.audio_status, `音轨:${material.audio_status}`)}
|
||||
{stepTag(material.transcription_status, `转写:${material.transcription_status}`)}
|
||||
</Space>
|
||||
{material.failure_reason ? <Alert type="error" showIcon title={`失败步骤:${material.failed_step || '未记录'};${material.failure_reason}`} /> : null}
|
||||
{material.failure_reason ? (
|
||||
<Alert type="error" showIcon title={`失败步骤:${material.failed_step || '未记录'};${material.failure_reason}`} />
|
||||
) : null}
|
||||
<div>
|
||||
<Typography.Text strong>仿写要求</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>必须先确认要求,才允许保存可编辑结果。</Typography.Paragraph>
|
||||
@@ -363,38 +337,74 @@ export default function Page() {
|
||||
value={rewriteRequirement}
|
||||
onChange={(event) => setRewriteRequirement(event.target.value)}
|
||||
disabled={Boolean(material.rewrite_confirmed_at) || materialPending}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
{material.rewrite_confirmed_at ? (
|
||||
<Flex>
|
||||
<Space>
|
||||
<Button onClick={generateRewrite} disabled={materialPending}>
|
||||
生成仿写草稿
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>生成结果不会自动发布,保存前必须人工复核。</Typography.Text>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Text>可编辑标题</Typography.Text>
|
||||
<Input value={rewriteTitle} onChange={(event) => setRewriteTitle(event.target.value)} style={{ marginTop: 4 }} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>可编辑口播</Typography.Text>
|
||||
<Input.TextArea rows={5} value={rewriteScript} onChange={(event) => setRewriteScript(event.target.value)} style={{ marginTop: 4 }} />
|
||||
</div>
|
||||
</div>
|
||||
{material.rewrite_confirmed_at ? (
|
||||
<>
|
||||
<Flex gap={8} align="center">
|
||||
<Button onClick={generateRewrite} disabled={materialPending}>
|
||||
生成仿写草稿
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>生成结果不会自动发布,保存前必须人工复核。</Typography.Text>
|
||||
</Flex>
|
||||
<div>
|
||||
<Typography.Text>可编辑标题</Typography.Text>
|
||||
<Input value={rewriteTitle} onChange={(event) => setRewriteTitle(event.target.value)} style={{ marginTop: 4 }} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>可编辑口播</Typography.Text>
|
||||
<Input.TextArea rows={5} value={rewriteScript} onChange={(event) => setRewriteScript(event.target.value)} style={{ marginTop: 4 }} />
|
||||
</div>
|
||||
<div>
|
||||
<Button type="primary" onClick={saveRewrite} disabled={materialPending}>
|
||||
保存仿写结果
|
||||
</Button>
|
||||
</Flex>
|
||||
) : (
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<Button type="primary" onClick={confirmRewrite} disabled={materialPending || !materialReadyForConfirm}>
|
||||
确认仿写要求
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Drawer open={Boolean(workDetail)} onClose={() => setWorkDetail(null)} size="large" title="作品详情">
|
||||
{workDetail ? (
|
||||
<Flex vertical gap={12}>
|
||||
{workDetail.cover_url ? (
|
||||
<img src={workDetail.cover_url} alt="作品封面" style={{ maxHeight: 256, borderRadius: 4, border: '1px solid #f0f0f0' }} />
|
||||
) : null}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>ID:{workDetail.work_key || workDetail.id}</Typography.Text>
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{workDetail.body || workDetail.title || '暂无正文'}</Typography.Paragraph>
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">来源:{workDetail.source_type || '—'}</Typography.Text>
|
||||
{statusTag(workDetail.published_at_status)}
|
||||
{workDetail.original_url ? (
|
||||
<Typography.Link href={workDetail.original_url} target="_blank">
|
||||
打开原文
|
||||
</Typography.Link>
|
||||
) : null}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="collected_at"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={workMetrics}
|
||||
columns={[
|
||||
{ title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
|
||||
{ title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
|
||||
{ title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
|
||||
{ title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
|
||||
]}
|
||||
locale={{ emptyText: '暂无指标记录。' }}
|
||||
/>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Drawer>
|
||||
<Modal
|
||||
open={Boolean(pendingSwitchWork)}
|
||||
title="放弃未保存的素材文稿"
|
||||
@@ -405,6 +415,6 @@ export default function Page() {
|
||||
>
|
||||
当前素材文稿尚未保存,确定放弃并切换吗?
|
||||
</Modal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ export function conflictMessage(error: any, conflictHint?: string): string {
|
||||
|
||||
export const platforms = [
|
||||
{ value: 'douyin', label: '抖音' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
{ value: 'wechat-official', label: '公众号' },
|
||||
{ value: 'kuaishou', label: '快手' },
|
||||
];
|
||||
|
||||
export function platformLabel(value?: string): string {
|
||||
@@ -39,7 +36,6 @@ 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 '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user