feat(web): 迁移 creator 四页(竞品分析/采集设置/运营工作台),替换审计发现的空白 stub
- competitors:作品列表 + 七项筛选(补 api filterKeys 缺失的 published_at_status,归档版白名单漏项导致筛选被静默丢弃)+ 详情 + 素材三步处理与仿写流程
- settings:antd Tabs + Form 四类配置,数值字段清空提交 0 对齐归档 Number('') 语义
- workbench:评论/线索/规则/事件/私信/操作记录六页签,SSE 实时订阅 + 5s 重连(api 新增 creatorSubscribe),回复/私信草稿持久化、幂等 operation_key、逐次人工确认
- $id 路由与归档一致复用竞品分析页;删除遗留 settings.tsx/workbench.tsx 空白 stub
This commit is contained in:
@@ -1,4 +1,2 @@
|
||||
// TODO: 从 web.archived 迁移 creator/competitors/$id
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
// 与归档版路由一致:/creator/competitors/:id 复用竞品分析页面本身。
|
||||
export { default } from '../index';
|
||||
|
||||
@@ -1,4 +1,413 @@
|
||||
// TODO: 从 web.archived 迁移 creator/competitors/index
|
||||
// 竞品分析:语义对齐 web.archived/src/features/creator/competitors/CreatorCompetitorsPage.jsx(提交 d97cade)。
|
||||
// 仅用 antd 默认组件;筛选自动刷新、手动上一页/下一页(后端 has_next)、素材三步处理与仿写流程原样保留。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Card, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
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: 'invalid', label: '无效时间' },
|
||||
];
|
||||
|
||||
interface Filters {
|
||||
platform?: string;
|
||||
minLikes?: number;
|
||||
minComments?: number;
|
||||
minShares?: number;
|
||||
publishedAtStatus?: string;
|
||||
publishedAfter?: string;
|
||||
publishedBefore?: string;
|
||||
}
|
||||
|
||||
function statusTag(tone: 'success' | 'error' | 'default', label: string) {
|
||||
return <Tag color={tone === 'default' ? undefined : tone}>{label}</Tag>;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
const [works, setWorks] = useState<any[]>([]);
|
||||
const [workPage, setWorkPage] = useState(1);
|
||||
const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
|
||||
const [filters, setFilters] = useState<Filters>({});
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [workDetail, setWorkDetail] = useState<any>(null);
|
||||
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
|
||||
const [workDetailPending, setWorkDetailPending] = useState(false);
|
||||
const [material, setMaterial] = useState<any>(null);
|
||||
const [materialPending, setMaterialPending] = useState(false);
|
||||
const [rewriteRequirement, setRewriteRequirement] = useState('');
|
||||
const [rewriteTitle, setRewriteTitle] = useState('');
|
||||
const [rewriteScript, setRewriteScript] = useState('');
|
||||
const [pendingSwitchWork, setPendingSwitchWork] = useState<string | null>(null);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
async function load() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getList({
|
||||
resource: 'creator-works',
|
||||
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),
|
||||
published_at_status: filters.publishedAtStatus,
|
||||
published_after: filters.publishedAfter ? `${filters.publishedAfter}T00:00:00Z` : undefined,
|
||||
published_before: filters.publishedBefore ? `${filters.publishedBefore}T23:59:59Z` : undefined,
|
||||
},
|
||||
});
|
||||
setWorks(result.data);
|
||||
setWorkPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) });
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 调整条件后自动刷新;条件变化时回到第一页(对齐归档版两个 useEffect)。
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [filters, workPage]);
|
||||
useEffect(() => {
|
||||
setWorkPage(1);
|
||||
}, [filters]);
|
||||
|
||||
const materialDirty =
|
||||
material &&
|
||||
(rewriteRequirement !== (material.rewrite_requirement || '') ||
|
||||
rewriteTitle !== (material.generated_title || '') ||
|
||||
rewriteScript !== (material.generated_script || ''));
|
||||
|
||||
async function openWorkDetail(workID: string) {
|
||||
setWorkDetailPending(true);
|
||||
try {
|
||||
const [detail, metrics] = await Promise.all([
|
||||
creatorGet(`/creator/works/${encodeURIComponent(workID)}`),
|
||||
creatorGet(`/creator/works/${encodeURIComponent(workID)}/metrics`),
|
||||
]);
|
||||
setWorkDetail(detail);
|
||||
setWorkMetrics(metrics || []);
|
||||
} catch (actionError) {
|
||||
messageApi.error(conflictMessage(actionError, '作品详情读取失败'));
|
||||
} finally {
|
||||
setWorkDetailPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectMaterial(workID: string) {
|
||||
if (materialDirty) {
|
||||
setPendingSwitchWork(workID);
|
||||
return;
|
||||
}
|
||||
doSelectMaterial(workID);
|
||||
}
|
||||
|
||||
async function doSelectMaterial(workID: string) {
|
||||
setPendingSwitchWork(null);
|
||||
setMaterialPending(true);
|
||||
try {
|
||||
const result = await creatorAction(`/creator/works/${encodeURIComponent(workID)}/material/select`);
|
||||
setMaterial(result);
|
||||
setRewriteRequirement(result.rewrite_requirement || '');
|
||||
setRewriteTitle(result.generated_title || '');
|
||||
setRewriteScript(result.generated_script || '');
|
||||
messageApi.info('素材已选择;点击开始处理后才会执行真实下载、音轨提取和转写。');
|
||||
} catch (actionError) {
|
||||
messageApi.error(conflictMessage(actionError, '素材选择失败'));
|
||||
} finally {
|
||||
setMaterialPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function processMaterial() {
|
||||
if (!material) return;
|
||||
setMaterialPending(true);
|
||||
try {
|
||||
const result = await creatorAction(`/creator/works/${encodeURIComponent(material.work_id)}/material/process`);
|
||||
setMaterial(result);
|
||||
const failedStep =
|
||||
result.failed_step || ['download', 'audio', 'transcription'].find((step) => result[`${step}_status`] === 'failed');
|
||||
if (failedStep) {
|
||||
messageApi.warning(`素材处理未完成(${failedStep}):${result.failure_reason || '请查看步骤状态后重试。'}`);
|
||||
} else {
|
||||
messageApi.success('素材处理完成。');
|
||||
}
|
||||
} catch (actionError) {
|
||||
messageApi.error(conflictMessage(actionError, '素材处理失败'));
|
||||
} finally {
|
||||
setMaterialPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRewrite() {
|
||||
if (!material) return;
|
||||
setMaterialPending(true);
|
||||
try {
|
||||
const result = await creatorAction(`/creator/works/${encodeURIComponent(material.work_id)}/material/rewrite/confirm`, {
|
||||
requirement: rewriteRequirement,
|
||||
});
|
||||
setMaterial(result);
|
||||
messageApi.info('已确认仿写要求,请在可编辑字段中复核并保存结果。');
|
||||
} catch (actionError) {
|
||||
messageApi.error(conflictMessage(actionError, '仿写确认失败'));
|
||||
} finally {
|
||||
setMaterialPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateRewrite() {
|
||||
if (!material) return;
|
||||
setMaterialPending(true);
|
||||
try {
|
||||
const result = await creatorAction(`/creator/works/${encodeURIComponent(material.work_id)}/material/rewrite/generate`);
|
||||
setMaterial(result);
|
||||
setRewriteTitle(result.generated_title || '');
|
||||
setRewriteScript(result.generated_script || '');
|
||||
messageApi.info('仿写草稿已生成,请人工复核后保存。');
|
||||
} catch (actionError) {
|
||||
messageApi.error(conflictMessage(actionError, '仿写草稿生成失败'));
|
||||
} finally {
|
||||
setMaterialPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRewrite() {
|
||||
if (!material) return;
|
||||
setMaterialPending(true);
|
||||
try {
|
||||
const result = await creatorUpdate(`/creator/works/${encodeURIComponent(material.work_id)}/material/rewrite`, {
|
||||
title: rewriteTitle,
|
||||
script: rewriteScript,
|
||||
});
|
||||
setMaterial(result);
|
||||
messageApi.info('仿写标题和口播已保存,可继续人工复核。');
|
||||
} catch (actionError) {
|
||||
messageApi.error(conflictMessage(actionError, '仿写结果保存失败'));
|
||||
} finally {
|
||||
setMaterialPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
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' &&
|
||||
!['not_started', 'running', 'failed'].includes(material.audio_status) &&
|
||||
!['not_started', 'running', 'failed'].includes(material.transcription_status);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<Card title="作品与指标" extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>共 {workPageInfo.total} 条</Typography.Text>}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>时间窗口、指标阈值和“不可用”字段由后端原样保留。</Typography.Paragraph>
|
||||
<div style={{ marginBottom: 16, background: '#fafafa', padding: 16, borderRadius: 8 }}>
|
||||
<Typography.Text strong>筛选作品</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>调整条件后自动刷新</Typography.Text>
|
||||
<Space size={12} wrap style={{ marginTop: 12, 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 }))} />
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部状态"
|
||||
allowClear
|
||||
value={filters.publishedAtStatus}
|
||||
onChange={(value) => setFilters((f) => ({ ...f, publishedAtStatus: value }))}
|
||||
options={publishedAtStatusOptions}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Table rowKey="id" columns={columns} dataSource={works} loading={pending} pagination={false} locale={{ emptyText: '暂无符合条件的作品。' }} />
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前页 {works.length} 条 · 共 {workPageInfo.total} 条
|
||||
</Typography.Text>
|
||||
<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>
|
||||
{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>}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
{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}
|
||||
</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: '暂无指标记录。' }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
{material ? (
|
||||
<Card size="small" title="素材处理" style={{ marginTop: 16 }}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>每一步都保留真实状态;失败不会被显示为成功。</Typography.Paragraph>
|
||||
<Button type="primary" onClick={processMaterial} loading={materialPending || material.download_status === 'running'} style={{ marginBottom: 16 }}>
|
||||
开始处理素材
|
||||
</Button>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<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}`)}
|
||||
</Space>
|
||||
{material.failure_reason ? <Alert type="error" showIcon message={`失败步骤:${material.failed_step || '未记录'};${material.failure_reason}`} /> : null}
|
||||
<div>
|
||||
<Typography.Text strong>仿写要求</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>必须先确认要求,才允许保存可编辑结果。</Typography.Paragraph>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={rewriteRequirement}
|
||||
onChange={(event) => setRewriteRequirement(event.target.value)}
|
||||
disabled={Boolean(material.rewrite_confirmed_at) || materialPending}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
{material.rewrite_confirmed_at ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<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>
|
||||
<Button type="primary" onClick={saveRewrite} disabled={materialPending}>
|
||||
保存仿写结果
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Button type="primary" onClick={confirmRewrite} disabled={materialPending || !materialReadyForConfirm}>
|
||||
确认仿写要求
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
</Card>
|
||||
<Modal
|
||||
open={Boolean(pendingSwitchWork)}
|
||||
title="放弃未保存的素材文稿"
|
||||
okText="放弃并切换"
|
||||
cancelText="继续编辑"
|
||||
onOk={() => pendingSwitchWork && doSelectMaterial(pendingSwitchWork)}
|
||||
onCancel={() => setPendingSwitchWork(null)}
|
||||
>
|
||||
当前素材文稿尚未保存,确定放弃并切换吗?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// TODO: 从 web.archived 迁移 creator/settings
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// 采集设置:语义对齐 web.archived CreatorSettingsPage.jsx(提交 d97cade)。
|
||||
// 四类配置用 antd Tabs 组织;仅用 antd 默认组件。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Button, Card, Checkbox, Form, Input, InputNumber, Tabs, Typography, message } from 'antd';
|
||||
import { creatorGet, creatorUpdate } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
|
||||
const numericFields = new Set([
|
||||
'lookback_days',
|
||||
'new_work_interval_seconds',
|
||||
'metric_initial_interval_seconds',
|
||||
'metric_multiplier',
|
||||
'metric_max_interval_seconds',
|
||||
'metric_age_seconds',
|
||||
]);
|
||||
|
||||
const editableFields = [
|
||||
'lookback_days',
|
||||
'new_work_interval_seconds',
|
||||
'metric_initial_interval_seconds',
|
||||
'metric_multiplier',
|
||||
'metric_max_interval_seconds',
|
||||
'metric_age_seconds',
|
||||
'ai_provider',
|
||||
'ai_model',
|
||||
'ai_configured',
|
||||
'transcription_provider',
|
||||
'transcription_model',
|
||||
'transcription_configured',
|
||||
];
|
||||
|
||||
const editableSettings = (value: any) => Object.fromEntries(editableFields.map((field) => [field, value[field]]));
|
||||
|
||||
export default function Page() {
|
||||
const [form] = Form.useForm();
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await creatorGet('/creator/settings');
|
||||
form.setFieldsValue(editableSettings(result));
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function save(values: any) {
|
||||
setBusy(true);
|
||||
try {
|
||||
// 数值字段清空时归零,对齐归档版 Number('') 的提交语义;服务侧字段为必填数值。
|
||||
const payload = Object.fromEntries(editableFields.map((field) => [field, numericFields.has(field) ? values[field] ?? 0 : values[field]]));
|
||||
const result = await creatorUpdate('/creator/settings', payload);
|
||||
form.setFieldsValue(editableSettings(result));
|
||||
messageApi.success('采集与 AI 配置已保存。');
|
||||
} catch (saveError) {
|
||||
messageApi.error(conflictMessage(saveError, '设置保存失败'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (pending) return <Card loading />;
|
||||
if (error) return <Alert type="error" showIcon message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<Typography.Paragraph type="secondary">按配置类别管理 UTC 采集窗口、指标采集和已批准的服务。</Typography.Paragraph>
|
||||
<Card>
|
||||
<Form form={form} layout="vertical" onFinish={save} initialValues={{}}>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'collection',
|
||||
label: '采集策略',
|
||||
children: (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<Form.Item name="lookback_days" label="竞品回溯天数" extra="首次回溯默认 30 天;不会猜测缺失发布时间。">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="new_work_interval_seconds" label="新作品/一级评论间隔(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
label: '指标采集',
|
||||
children: (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<Form.Item name="metric_initial_interval_seconds" label="指标首次间隔(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="metric_multiplier" label="指标间隔倍数">
|
||||
<InputNumber min={1.01} step={0.01} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="metric_max_interval_seconds" label="指标最大间隔(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="metric_age_seconds" label="指标监测最长时长(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: 'AI 服务',
|
||||
children: (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<Form.Item name="ai_provider" label="AI Provider">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="ai_model" label="AI Model">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="ai_configured" valuePropName="checked">
|
||||
<Checkbox>已完成 AI 配置审批</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'transcription',
|
||||
label: '转写服务',
|
||||
children: (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<Form.Item name="transcription_provider" label="转写 Provider">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="transcription_model" label="转写 Model">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="transcription_configured" valuePropName="checked">
|
||||
<Checkbox>已完成转写配置审批</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" htmlType="submit" loading={busy}>
|
||||
{busy ? '保存中…' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// TODO: 从 web.archived 迁移 creator/workbench
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+42
-2
@@ -1,6 +1,6 @@
|
||||
// 领域 API 层:资源路径与语义逐一对照 web.archived/src/shared/api/dataProvider.js。
|
||||
// 全部经由 requestErrorConfig.request(Basic Auth + 401 全局登出)。
|
||||
import { jsonOptions, request } from '@/requestErrorConfig';
|
||||
import { AUTH_STORAGE_KEY, RequestError, jsonOptions, request, unauthorized } from '@/requestErrorConfig';
|
||||
|
||||
export const resourcePaths = {
|
||||
browsers: '/browsers',
|
||||
@@ -33,7 +33,7 @@ const filterKeys: Partial<Record<Resource, string[]>> = {
|
||||
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-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares'],
|
||||
'creator-comments': ['platform', 'work_id'],
|
||||
'creator-leads': ['platform'],
|
||||
'creator-operations': ['account_id'],
|
||||
@@ -156,3 +156,43 @@ export function creatorUpdate(path: string, data: unknown) {
|
||||
export function creatorSyncConversation(id: string, limit = 200) {
|
||||
return request(`/creator/conversations/${encodeURIComponent(id)}/sync?limit=${encodeURIComponent(limit)}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// SSE 订阅:语义对齐归档版 dataProvider.creatorSubscribe(fetch 流式读取,按 data: 帧分发)。
|
||||
export async function creatorSubscribe(
|
||||
path: string,
|
||||
onMessage: (frame: string) => void,
|
||||
signal: AbortSignal,
|
||||
onStatus?: (status: 'connected' | 'disconnected') => void,
|
||||
): Promise<void> {
|
||||
const auth = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
const headers: Record<string, string> = {};
|
||||
if (auth) headers.Authorization = `Basic ${btoa(auth)}`;
|
||||
const response = await fetch(`/api${path}`, { headers, signal });
|
||||
if (response.status === 401) unauthorized();
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new RequestError(body.error || `请求失败 (${response.status})`, response.status, body);
|
||||
}
|
||||
if (!response.body) throw new RequestError('业务更新流不可用', response.status);
|
||||
const reader = response.body.getReader();
|
||||
onStatus?.('connected');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const dispatch = (frame: string) => {
|
||||
if (frame.split(/\r\n|\n|\r/).some((line) => line.startsWith('data:'))) onMessage(frame);
|
||||
};
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = /\r\n\r\n|\n\n|\r\r/.exec(buffer))) {
|
||||
dispatch(buffer.slice(0, match.index));
|
||||
buffer = buffer.slice(match.index + match[0].length);
|
||||
}
|
||||
if (done) {
|
||||
if (buffer.trim()) dispatch(buffer);
|
||||
onStatus?.('disconnected');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user