From 3e87f67442a7ff76af26b0373bc5847e6b135446 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 22 Sep 2026 15:26:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=BF=81=E7=A7=BB=20creator=20?= =?UTF-8?q?=E5=9B=9B=E9=A1=B5=EF=BC=88=E7=AB=9E=E5=93=81=E5=88=86=E6=9E=90?= =?UTF-8?q?/=E9=87=87=E9=9B=86=E8=AE=BE=E7=BD=AE/=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=EF=BC=89=EF=BC=8C=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../pages/creator/competitors/$id/index.tsx | 6 +- web/src/pages/creator/competitors/index.tsx | 413 ++++++- web/src/pages/creator/settings.tsx | 4 - web/src/pages/creator/settings/index.tsx | 163 +++ web/src/pages/creator/workbench.tsx | 4 - web/src/pages/creator/workbench/index.tsx | 1067 +++++++++++++++++ web/src/services/api.ts | 44 +- 7 files changed, 1685 insertions(+), 16 deletions(-) delete mode 100644 web/src/pages/creator/settings.tsx create mode 100644 web/src/pages/creator/settings/index.tsx delete mode 100644 web/src/pages/creator/workbench.tsx create mode 100644 web/src/pages/creator/workbench/index.tsx diff --git a/web/src/pages/creator/competitors/$id/index.tsx b/web/src/pages/creator/competitors/$id/index.tsx index ef9f4e5..00a463d 100644 --- a/web/src/pages/creator/competitors/$id/index.tsx +++ b/web/src/pages/creator/competitors/$id/index.tsx @@ -1,4 +1,2 @@ -// TODO: 从 web.archived 迁移 creator/competitors/$id -export default function Page() { - return null; -} +// 与归档版路由一致:/creator/competitors/:id 复用竞品分析页面本身。 +export { default } from '../index'; diff --git a/web/src/pages/creator/competitors/index.tsx b/web/src/pages/creator/competitors/index.tsx index 9618931..b821e45 100644 --- a/web/src/pages/creator/competitors/index.tsx +++ b/web/src/pages/creator/competitors/index.tsx @@ -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 {label}; +} + export default function Page() { - return null; + const [works, setWorks] = useState([]); + const [workPage, setWorkPage] = useState(1); + const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false }); + const [filters, setFilters] = useState({}); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [workDetail, setWorkDetail] = useState(null); + const [workMetrics, setWorkMetrics] = useState([]); + const [workDetailPending, setWorkDetailPending] = useState(false); + const [material, setMaterial] = useState(null); + const [materialPending, setMaterialPending] = useState(false); + const [rewriteRequirement, setRewriteRequirement] = useState(''); + const [rewriteTitle, setRewriteTitle] = useState(''); + const [rewriteScript, setRewriteScript] = useState(''); + const [pendingSwitchWork, setPendingSwitchWork] = useState(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 = [ + { + title: '作品', + dataIndex: 'title', + width: 280, + render: (_, work) => ( +
+ + + {work.author_name || work.source_id} · {work.published_at_status} + +
+ ), + }, + { + title: '发布时间', + dataIndex: 'published_at', + render: (value?: string) => ( + + {value ? dateTime(value) : '待核验'} + + ), + }, + { title: '点赞 / 评论 / 转发', key: 'metrics', render: (_, work) => {work.likes ?? '—'} / {work.comments_count ?? '—'} / {work.shares ?? '—'} }, + { + title: '指标下次采集', + dataIndex: 'next_metric_at', + render: (value?: string, work?: any) => ( + + {value ? dateTime(value) : work?.metric_stop_reason || '—'} + + ), + }, + { + title: '操作', + key: 'actions', + align: 'right', + render: (_, work) => ( + + + + + ), + }, + ]; + + const materialReadyForConfirm = + material && + material.download_status === 'succeeded' && + !['not_started', 'running', 'failed'].includes(material.audio_status) && + !['not_started', 'running', 'failed'].includes(material.transcription_status); + + return ( +
+ {contextHolder} + 共 {workPageInfo.total} 条}> + 时间窗口、指标阈值和“不可用”字段由后端原样保留。 +
+ 筛选作品 + 调整条件后自动刷新 + + setFilters((f) => ({ ...f, publishedAtStatus: value }))} + options={publishedAtStatusOptions} + /> + setFilters((f) => ({ ...f, publishedAfter: e.target.value }))} /> + setFilters((f) => ({ ...f, publishedBefore: e.target.value }))} /> + +
+ {error ? ( + 重试} /> + ) : null} + +
+ + 当前页 {works.length} 条 · 共 {workPageInfo.total} 条 + + + + + +
+ {workDetail ? ( + 作品详情 {workDetail.work_key || workDetail.id}} + extra={} + style={{ marginTop: 16 }} + > + + {workDetail.cover_url ? 作品封面 : null} + {workDetail.body || workDetail.title || '暂无正文'} + + 来源:{workDetail.source_type || '—'} + 状态:{workDetail.published_at_status || '—'} + {workDetail.original_url ? ( + + 打开原文 + + ) : null} + +
dateTime(value) }, + { title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' }, + { title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' }, + { title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' }, + ]} + locale={{ emptyText: '暂无指标记录。' }} + /> + + + ) : null} + {material ? ( + + 每一步都保留真实状态;失败不会被显示为成功。 + + + + {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}`)} + + {material.failure_reason ? : null} +
+ 仿写要求 + 必须先确认要求,才允许保存可编辑结果。 + setRewriteRequirement(event.target.value)} + disabled={Boolean(material.rewrite_confirmed_at) || materialPending} + style={{ marginBottom: 12 }} + /> + {material.rewrite_confirmed_at ? ( + + + + 生成结果不会自动发布,保存前必须人工复核。 + +
+ 可编辑标题 + setRewriteTitle(event.target.value)} style={{ marginTop: 4 }} /> +
+
+ 可编辑口播 + setRewriteScript(event.target.value)} style={{ marginTop: 4 }} /> +
+ +
+ ) : ( + + )} +
+
+
+ ) : null} + + pendingSwitchWork && doSelectMaterial(pendingSwitchWork)} + onCancel={() => setPendingSwitchWork(null)} + > + 当前素材文稿尚未保存,确定放弃并切换吗? + + + ); } diff --git a/web/src/pages/creator/settings.tsx b/web/src/pages/creator/settings.tsx deleted file mode 100644 index 50e044d..0000000 --- a/web/src/pages/creator/settings.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: 从 web.archived 迁移 creator/settings -export default function Page() { - return null; -} diff --git a/web/src/pages/creator/settings/index.tsx b/web/src/pages/creator/settings/index.tsx new file mode 100644 index 0000000..96a86b3 --- /dev/null +++ b/web/src/pages/creator/settings/index.tsx @@ -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(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 ; + if (error) return 重试} />; + + return ( +
+ {contextHolder} + 按配置类别管理 UTC 采集窗口、指标采集和已批准的服务。 + +
+ + + + + + + +
+ ), + }, + { + key: 'metrics', + label: '指标采集', + children: ( +
+ + + + + + + + + + + + +
+ ), + }, + { + key: 'ai', + label: 'AI 服务', + children: ( +
+ + + + + + + + 已完成 AI 配置审批 + +
+ ), + }, + { + key: 'transcription', + label: '转写服务', + children: ( +
+ + + + + + + + 已完成转写配置审批 + +
+ ), + }, + ]} + /> +
+ +
+ +
+ + ); +} diff --git a/web/src/pages/creator/workbench.tsx b/web/src/pages/creator/workbench.tsx deleted file mode 100644 index ffb1202..0000000 --- a/web/src/pages/creator/workbench.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: 从 web.archived 迁移 creator/workbench -export default function Page() { - return null; -} diff --git a/web/src/pages/creator/workbench/index.tsx b/web/src/pages/creator/workbench/index.tsx new file mode 100644 index 0000000..05f7d50 --- /dev/null +++ b/web/src/pages/creator/workbench/index.tsx @@ -0,0 +1,1067 @@ +// 运营工作台:语义对齐 web.archived/src/features/creator/workbench/CreatorWorkbenchPage.jsx(提交 d97cade)。 +// 六个页签:评论/线索/规则/事件监听/私信/操作记录;实时更新走 /creator/updates SSE;仅用 antd 默认组件。 +import { useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { Alert, Button, Card, Checkbox, Col, Empty, Input, List, Modal, Row, Select, Space, Spin, Table, Tabs, Tag, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { create, creatorAction, creatorCreate, creatorGet, creatorSubscribe, creatorSyncConversation, creatorUpdate, getList } from '@/services/api'; +import { conflictMessage, dateTime } from '@/utils/helpers'; + +const tabs = [ + ['comments', '评论'], + ['leads', '线索'], + ['rules', '规则'], + ['events', '事件监听'], + ['dms', '私信'], + ['operations', '操作记录'], +] as const; +type TabKey = (typeof tabs)[number][0]; + +function parseKeywords(value: string): string[] { + if (!value.trim()) return []; + const values = value.split(',').map((item) => item.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '')); + if (values.some((item) => !item)) throw new Error('关键词不能为空'); + return values; +} + +function newOperationKey(): string { + const crypto = globalThis.crypto; + if (typeof crypto?.randomUUID === 'function') return `manual-${crypto.randomUUID()}`; + if (typeof crypto?.getRandomValues !== 'function') { + throw new Error('当前浏览器不支持安全操作标识,请使用 HTTPS 或受支持的浏览器'); + } + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return `manual-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`; +} + +const replyDraftKey = (commentID: string) => `creatorhub.reply.${commentID}`; +const dmDraftKey = (accountID: string, conversationID: string) => `creatorhub.dm.${accountID}.${conversationID}`; +const readDraft = (key: string): any => { + try { + const value = JSON.parse(localStorage.getItem(key) || 'null'); + return value && typeof value === 'object' ? value : null; + } catch { + return null; + } +}; +const writeDraft = (key: string, value: unknown) => localStorage.setItem(key, JSON.stringify(value)); +const removeDraft = (key: string) => localStorage.removeItem(key); + +function operationStatus(state?: string, reason?: string): string { + const labels: Record = { + succeeded: '成功(已获得平台证据)', + failed: '失败(平台明确拒绝或执行前失败)', + blocked: '已阻止(未发送)', + processing: '处理中(尚未获得最终结果)', + uncertain: '结果不明(禁止自动重试)', + }; + return `${labels[state || ''] || state || '未知状态'}${reason ? `:${reason}` : ''}`; +} + +function statusTag(tone: 'success' | 'warning' | 'error' | 'default', label: string) { + return {label}; +} + +function PageNav({ page, total, count, hasNext, onPageChange }: { page: number; total: number; count: number; hasNext: boolean; onPageChange: (page: number) => void }) { + return ( +
+ 当前页 {count} 条 · 共 {total} 条 + + + + +
+ ); +} + +function TabShell({ pending, error, onRetry, children }: { pending: boolean; error: any; onRetry: () => void; children: ReactNode }) { + return ( +
+ {error ? ( + 重试} /> + ) : null} + {children} +
+ ); +} + +function RequiredMark() { + return *; +} + +export default function Page() { + const [tab, setTab] = useState('comments'); + const [data, setData] = useState([]); + const [dataTab, setDataTab] = useState(''); + const [listPage, setListPage] = useState(1); + const [listPageInfo, setListPageInfo] = useState({ total: 0, hasNext: false }); + const [rules, setRules] = useState([]); + const [accounts, setAccounts] = useState([]); + const [conversations, setConversations] = useState([]); + const [conversationAccountID, setConversationAccountID] = useState(''); + const [messages, setMessages] = useState([]); + const [messagePage, setMessagePage] = useState(1); + const [messagePageInfo, setMessagePageInfo] = useState({ total: 0, hasNext: false }); + const [messagePending, setMessagePending] = useState(false); + const [messageError, setMessageError] = useState(null); + const [messageRetry, setMessageRetry] = useState(0); + const [messageSyncing, setMessageSyncing] = useState(false); + const [updateStatus, setUpdateStatus] = useState<'connected' | 'connecting' | 'disconnected'>('disconnected'); + const [conversationID, setConversationID] = useState(''); + const [dmAccountID, setDmAccountID] = useState(''); + const [dmText, setDmText] = useState(''); + const [dmConfirm, setDmConfirm] = useState(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [analyzing, setAnalyzing] = useState(''); + const [selectedCommentIDs, setSelectedCommentIDs] = useState([]); + const [ruleForm, setRuleForm] = useState({ name: '', source_type: 'all', topic: '', include_keywords: '', exclude_keywords: '', ai_requirement: '' }); + const [editingRuleID, setEditingRuleID] = useState(''); + const [replyMode, setReplyMode] = useState<'comment' | 'dm'>('comment'); + const [reply, setReply] = useState({ account_id: '', text: '', target_uid: '', target_comment_id: '', operation_key: '' }); + const [confirm, setConfirm] = useState(null); + const [busy, setBusy] = useState(false); + const [dmOperationKey, setDmOperationKey] = useState(''); + const [pendingDMSwitch, setPendingDMSwitch] = useState<{ kind: 'account' | 'conversation'; id: string } | null>(null); + const loadSequence = useRef(0); + const messageSequence = useRef(0); + const [messageApi, contextHolder] = message.useMessage(); + + const visibleData = dataTab === tab ? data : []; + const visibleConversations = conversationAccountID === dmAccountID ? conversations : []; + const replyTarget = + visibleData.find((item: any) => item.id === reply.target_comment_id) || + visibleData.find((item: any) => item.comment?.id === reply.target_comment_id)?.comment; + + async function load() { + const sequence = ++loadSequence.current; + setPending(true); + setError(null); + setDataTab(''); + try { + if (tab === 'comments') { + const [comments, ruleList] = await Promise.all([ + getList({ resource: 'creator-comments', page: listPage, pageSize: 25 }), + getList({ resource: 'creator-rules' }), + ]); + if (sequence !== loadSequence.current) return; + setData(comments.data); + setListPageInfo({ total: comments.total, hasNext: Boolean(comments.hasNext) }); + setDataTab('comments'); + setRules(ruleList.data); + } else if (tab === 'leads') { + const result = await getList({ resource: 'creator-leads' }); + if (sequence !== loadSequence.current) return; + setData(result.data); + setDataTab('leads'); + } else if (tab === 'events') { + const result = await getList({ resource: 'creator-events', page: listPage, pageSize: 25 }); + if (sequence !== loadSequence.current) return; + setData(result.data); + setListPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) }); + setDataTab('events'); + } else if (tab === 'rules') { + const result = await getList({ resource: 'creator-rules' }); + if (sequence !== loadSequence.current) return; + setData(result.data); + setDataTab('rules'); + } else if (tab === 'dms') { + if (!dmAccountID) { + setPending(false); + return; + } + const result = await getList({ resource: 'creator-conversations', filters: { account_id: dmAccountID } }); + if (sequence !== loadSequence.current) return; + setConversations(result.data); + setConversationAccountID(dmAccountID); + setData(result.data); + setDataTab('dms'); + setConversationID((current) => (result.data.some((item: any) => item.id === current) ? current : result.data[0]?.id || '')); + } else { + const result = await getList({ resource: 'creator-operations' }); + if (sequence !== loadSequence.current) return; + setData(result.data); + setDataTab('operations'); + } + } catch (loadError) { + if (sequence === loadSequence.current) setError(loadError); + } finally { + if (sequence === loadSequence.current) setPending(false); + } + } + + // 发送账号只拉一次,供回复与私信共用。 + useEffect(() => { + let active = true; + getList({ resource: 'creator-accounts' }) + .then((result) => { + if (!active) return; + setAccounts(result.data); + setDmAccountID((current) => current || result.data[0]?.id || ''); + }) + .catch((loadError) => { + if (active) setError(loadError); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (tab !== 'dms' || dmAccountID) load(); + }, [tab, dmAccountID, listPage]); + + function switchTab(nextTab: string) { + if (nextTab === tab) return; + loadSequence.current += 1; + setData([]); + setDataTab(''); + setListPage(1); + setListPageInfo({ total: 0, hasNext: false }); + setError(null); + setTab(nextTab as TabKey); + } + + // 私信消息记录:按会话与页码加载。 + useEffect(() => { + if (!conversationID || tab !== 'dms') { + setMessages([]); + setMessagePending(false); + setMessageError(null); + return undefined; + } + const sequence = ++messageSequence.current; + setMessages([]); + setMessagePending(true); + setMessageError(null); + creatorGet(`/creator/conversations/${encodeURIComponent(conversationID)}/messages?page=${messagePage}&page_size=25`) + .then((result) => { + if (sequence !== messageSequence.current) return; + const items = Array.isArray(result) ? result : result.data || []; + setMessages(items); + setMessagePageInfo({ + total: Array.isArray(result) ? items.length : (result.total ?? items.length), + hasNext: Array.isArray(result) ? false : Boolean(result.has_next), + }); + }) + .catch((loadError) => { + if (sequence === messageSequence.current) setMessageError(loadError); + }) + .finally(() => { + if (sequence === messageSequence.current) setMessagePending(false); + }); + return () => { + if (sequence === messageSequence.current) setMessagePending(false); + }; + }, [conversationID, tab, messageRetry, messagePage]); + + async function syncMessages() { + if (!conversationID || messageSyncing) return; + setMessageSyncing(true); + setMessageError(null); + try { + const result = await creatorSyncConversation(conversationID); + messageApi[result?.history_has_more ? 'warning' : 'success']( + result?.history_has_more + ? `已同步 ${result?.messages ?? 0} 条平台私信历史,仍有更多内容待同步。` + : `已同步 ${result?.messages ?? 0} 条平台私信历史。`, + ); + setMessageRetry((value) => value + 1); + } catch (syncError) { + setMessageError(syncError); + } finally { + setMessageSyncing(false); + } + } + + // 回复草稿:按评论持久化到 localStorage。 + useEffect(() => { + if (reply.target_comment_id) { + writeDraft(replyDraftKey(reply.target_comment_id), reply); + } + }, [reply]); + + // 实时更新:SSE 订阅 /creator/updates,断开后 5 秒重连(规则页签为静态配置,不订阅)。 + useEffect(() => { + const realtimeTabs = new Set(['comments', 'leads', 'events', 'dms', 'operations']); + if (!realtimeTabs.has(tab)) { + setUpdateStatus('disconnected'); + return undefined; + } + const controller = new AbortController(); + let stopped = false; + let retryTimer = 0; + const connect = () => { + if (stopped) return; + setUpdateStatus('connecting'); + creatorSubscribe( + '/creator/updates', + () => { + load(); + if (tab === 'dms') { + setMessageRetry((value) => value + 1); + } + }, + controller.signal, + (status) => setUpdateStatus(status), + ) + .then(() => { + if (!stopped) retryTimer = window.setTimeout(connect, 5000); + }) + .catch((streamError) => { + if (stopped || streamError.name === 'AbortError') return; + setUpdateStatus('disconnected'); + setMessageError(streamError); + retryTimer = window.setTimeout(connect, 5000); + }); + }; + connect(); + return () => { + stopped = true; + controller.abort(); + window.clearTimeout(retryTimer); + setUpdateStatus('disconnected'); + }; + }, [tab]); + + // 私信草稿:按发送账号+会话持久化。 + useEffect(() => { + if (!dmAccountID || !conversationID) return; + const draft = readDraft(dmDraftKey(dmAccountID, conversationID)); + setDmText(draft?.text || ''); + setDmOperationKey(draft?.operation_key || ''); + }, [dmAccountID, conversationID]); + useEffect(() => { + if (dmAccountID && conversationID) { + writeDraft(dmDraftKey(dmAccountID, conversationID), { operation_key: dmOperationKey, text: dmText }); + } + }, [dmAccountID, conversationID, dmOperationKey, dmText]); + + async function analyzeSelected(ruleID: string) { + if (!ruleID || !selectedCommentIDs.length) return; + setAnalyzing(`batch:${ruleID}`); + try { + const response = await creatorCreate('/creator/comments/analyze', { comment_ids: selectedCommentIDs, rule_id: ruleID }); + const failed = (response.items || []).filter((item: any) => item.error).length; + messageApi[failed ? 'warning' : 'success'](`已分析 ${selectedCommentIDs.length - failed} 条评论${failed ? `,${failed} 条失败` : ''}。`); + setSelectedCommentIDs([]); + } catch (actionError) { + messageApi.info(conflictMessage(actionError, '批量 AI 分析不可用,未生成虚假结果')); + } finally { + setAnalyzing(''); + } + } + + async function analyze(commentID: string, ruleID: string) { + setAnalyzing(`${commentID}:${ruleID}`); + try { + const result = await creatorCreate(`/creator/comments/${encodeURIComponent(commentID)}/analyze`, { rule_id: ruleID }); + messageApi.info(`分析结果:${result.status}。${result.reason || ''}`); + } catch (actionError) { + messageApi.info(conflictMessage(actionError, 'AI 分析不可用,未生成虚假结果')); + } finally { + setAnalyzing(''); + } + } + + async function createRule() { + setBusy(true); + try { + const variables = { + name: ruleForm.name, + enabled: true, + source_type: ruleForm.source_type, + topic: ruleForm.topic, + include_keywords: parseKeywords(ruleForm.include_keywords), + exclude_keywords: parseKeywords(ruleForm.exclude_keywords), + ai_requirement: ruleForm.ai_requirement, + }; + const result = editingRuleID + ? await creatorUpdate(`/creator/rules/${encodeURIComponent(editingRuleID)}`, variables) + : (await create('creator-rules', variables)).data; + const update = (items: any[]) => (editingRuleID ? items.map((item) => (item.id === editingRuleID ? result : item)) : [result, ...items]); + setData(update); + setDataTab('rules'); + setRules(update); + setEditingRuleID(''); + setRuleForm({ name: '', source_type: 'all', topic: '', include_keywords: '', exclude_keywords: '', ai_requirement: '' }); + messageApi.info('线索规则已保存。'); + } catch (createError) { + messageApi.error(conflictMessage(createError, '规则保存失败')); + } finally { + setBusy(false); + } + } + + async function toggleRule(rule: any) { + setBusy(true); + try { + const updated = await creatorAction(`/creator/rules/${encodeURIComponent(rule.id)}/${rule.enabled ? 'disable' : 'enable'}`); + setData((items) => items.map((item) => (item.id === rule.id ? updated : item))); + setRules((items) => items.map((item) => (item.id === rule.id ? updated : item))); + } catch (ruleError) { + messageApi.error(conflictMessage(ruleError, '规则状态更新失败')); + } finally { + setBusy(false); + } + } + + function requestReply(comment: any, mode: 'comment' | 'dm' = 'comment') { + setReplyMode(mode); + const draft = readDraft(replyDraftKey(comment.id)); + setReply({ + account_id: draft?.account_id || '', + text: draft?.text || '', + target_uid: comment.author_uid || '', + target_comment_id: comment.id, + operation_key: draft?.operation_key || '', + }); + messageApi.info(mode === 'dm' ? '请填写发送账号和私信文案,再进行逐次确认。' : '请填写发送账号和文案,再进行逐次确认。'); + } + + function askReplyConfirm(comment: any) { + if (!comment || !reply.account_id || !reply.target_uid || !reply.text.trim()) { + messageApi.info('人工回复需要发送账号、可用的目标 UID 和文案。'); + return; + } + try { + setReply((value) => ({ ...value, operation_key: value.operation_key || newOperationKey() })); + setConfirm(comment); + } catch (error) { + messageApi.info((error as Error).message); + } + } + + async function sendReply() { + if (!confirm) return; + setBusy(true); + try { + const created = await creatorCreate('/creator/operations', { + idempotency_key: reply.operation_key, + source: 'manual', + action: replyMode === 'dm' ? 'dm' : 'reply_comment', + platform: confirm.platform, + account_id: reply.account_id, + target_uid: reply.target_uid, + ...(replyMode === 'comment' ? { target_comment_id: reply.target_comment_id } : {}), + text: reply.text, + }); + const result = await creatorAction(`/creator/operations/${encodeURIComponent(created.id)}/execute`); + messageApi[result.state === 'succeeded' ? 'success' : 'warning'](operationStatus(result.state, result.reason)); + if (result.state === 'succeeded') { + removeDraft(replyDraftKey(reply.target_comment_id)); + setReply((value) => ({ ...value, operation_key: '' })); + } + } catch (sendError) { + messageApi.error(conflictMessage(sendError, '人工回复未发送')); + } finally { + setBusy(false); + setConfirm(null); + } + } + + const canSwitchDM = () => !dmText.trim(); + + function switchDMAccount(accountID: string) { + if (accountID === dmAccountID) return; + if (!canSwitchDM()) { + setPendingDMSwitch({ kind: 'account', id: accountID }); + return; + } + doSwitchDMAccount(accountID); + } + + function doSwitchDMAccount(accountID: string) { + setPendingDMSwitch(null); + setDmAccountID(accountID); + setConversationAccountID(''); + setConversations([]); + setConversationID(''); + setMessagePage(1); + setMessagePageInfo({ total: 0, hasNext: false }); + setDmText(''); + setDmOperationKey(''); + } + + function switchConversation(nextID: string) { + if (nextID === conversationID) return; + if (!canSwitchDM()) { + setPendingDMSwitch({ kind: 'conversation', id: nextID }); + return; + } + doSwitchConversation(nextID); + } + + function doSwitchConversation(nextID: string) { + setPendingDMSwitch(null); + setConversationID(nextID); + setMessagePage(1); + setMessagePageInfo({ total: 0, hasNext: false }); + setDmText(''); + setDmOperationKey(''); + } + + function askDMConfirm(conversation: any) { + if (!conversation || !dmAccountID || !dmText.trim()) { + messageApi.info('人工私信需要发送账号、会话和文案。'); + return; + } + let operationKey: string; + try { + operationKey = dmOperationKey || newOperationKey(); + } catch (error) { + messageApi.info((error as Error).message); + return; + } + setDmOperationKey(operationKey); + setDmConfirm({ ...conversation, operation_key: operationKey }); + } + + async function sendDM() { + if (!dmConfirm) return; + setBusy(true); + try { + const created = await creatorCreate('/creator/operations', { + idempotency_key: dmConfirm.operation_key || dmOperationKey, + source: 'manual', + action: 'dm', + platform: dmConfirm.platform, + account_id: dmAccountID, + target_uid: dmConfirm.peer_uid, + text: dmText, + }); + const result = await creatorAction(`/creator/operations/${encodeURIComponent(created.id)}/execute`); + messageApi[result.state === 'succeeded' ? 'success' : 'warning'](operationStatus(result.state, result.reason)); + if (result.state === 'succeeded') { + removeDraft(dmDraftKey(dmAccountID, dmConfirm.id)); + setDmText(''); + setDmOperationKey(''); + } + } catch (sendError) { + messageApi.error(conflictMessage(sendError, '人工私信未发送')); + } finally { + setBusy(false); + setDmConfirm(null); + } + } + + const accountOptions = accounts.map((account: any) => ({ value: account.id, label: account.name || account.id })); + const ruleOptions = rules.map((rule: any) => ({ value: rule.id, label: rule.name })); + + // 回复表单卡片:评论页签与线索页签共用。 + const replyFormCard = + reply.target_comment_id && replyTarget ? ( + + + +
+ 发送账号 + + setReply((value) => ({ ...value, text: event.target.value, operation_key: '' }))} + /> +
+ +
+ + {tab === 'comments' ? '回复' : ''}目标 UID:{reply.target_uid || '不可用,无法发送'} + +
+
+ ) : null; + + const commentsTab = ( + + {replyFormCard} +
+ + value && analyze(comment.id, value)} + disabled={!rules.length || Boolean(analyzing)} + options={ruleOptions} + /> + 仅分析,不会自动发送消息。 +
+ + ))} + + + + )} +
+ ); + + const leadsTab = ( + + {replyFormCard} + {!pending && !error && !visibleData.length ? ( + + ) : ( + + {visibleData.map((lead: any) => ( + +
+ + {lead.comment.author_name || '未知用户'} · {lead.comment.author_uid || 'UID 不可用'} + + + {statusTag('success', '线索')} + + + +
+ {lead.comment.content} + 规则:{lead.rule_ids.join('、')} +
+ {lead.results?.length ? ( + lead.results.map((result: any) => ( + + {result.status} + {result.reason ? `:${result.reason}` : ''} + {result.matched_keywords?.length ? `(关键词:${result.matched_keywords.join('、')})` : ''} + + )) + ) : ( + 尚未分析 + )} +
+
+ ))} +
+ )} +
+ ); + + const eventsTab = ( + + {!pending && !error && !visibleData.length ? ( + + ) : ( + <> + + {visibleData.map((event: any) => ( + +
+
+ + {event.event_type} · {event.interactor_uid || 'UID 不可用'} + + + 作品:{event.work_id || '—'} · 事件键:{event.event_key} + + {event.message_text ? ( + {event.message_text} + ) : null} +
+ {statusTag( + event.baseline || event.state === 'blocked' ? 'warning' : event.state === 'succeeded' ? 'success' : event.state === 'uncertain' ? 'warning' : 'default', + event.baseline ? `基线/不触发:${event.reason || '需重新建立边界'}` : event.state || '未知', + )} +
+ + 平台时间:{event.platform_event_at ? dateTime(event.platform_event_at) : '不可用'} · 网关接收: + {event.gateway_received_at ? dateTime(event.gateway_received_at) : '不可用'} · 控制面接收:{dateTime(event.received_at)} + +
+ ))} +
+ + + )} +
+ ); + + const rulesTab = ( + +
+ + +
+ 规则名称 + + setRuleForm({ ...ruleForm, name: event.target.value })} /> +
+
+ 来源范围 + + setRuleForm({ ...ruleForm, topic: event.target.value })} /> +
+
+ 包含关键词 + + setRuleForm({ ...ruleForm, include_keywords: event.target.value })} /> + 逗号分隔;只去除边缘空白,保留大小写和内部空格。 +
+
+ 排除关键词 + setRuleForm({ ...ruleForm, exclude_keywords: event.target.value })} /> +
+
+ AI 判断要求 + + setRuleForm({ ...ruleForm, ai_requirement: event.target.value })} /> +
+ + + {editingRuleID ? ( + + ) : null} + +
+
+ + + + {visibleData.map((rule: any) => ( +
+
+ {rule.name} + {statusTag(rule.enabled ? 'success' : 'default', rule.enabled ? '启用' : '停用')} +
+ + 来源:{rule.source_type || 'all'} · 主题:{rule.topic} + + 包含:{(rule.include_keywords || []).join('、')} + + + + +
+ ))} +
+ + + ); + + const dmsTab = ( +
+ +
+ 发送账号 + +
+ ); + + return ( +
+ {contextHolder} + ({ + key, + label, + children: + key === 'comments' + ? commentsTab + : key === 'leads' + ? leadsTab + : key === 'rules' + ? rulesTab + : key === 'events' + ? eventsTab + : key === 'dms' + ? dmsTab + : operationsTab, + }))} + /> + setConfirm(null)} + > + {confirm + ? `将使用账号 ${reply.account_id || '(未填写)'} 向 UID ${reply.target_uid || '(未填写)'} 回复:${reply.text || '(未填写)'}` + : ''} + + setDmConfirm(null)} + > + {dmConfirm + ? `将使用账号 ${dmAccountID || '(未填写)'} 向 UID ${dmConfirm.peer_uid || '(未填写)'} 发送:${dmText || '(未填写)'}` + : ''} + + { + if (pendingDMSwitch?.kind === 'account') doSwitchDMAccount(pendingDMSwitch.id); + else if (pendingDMSwitch?.kind === 'conversation') doSwitchConversation(pendingDMSwitch.id); + }} + onCancel={() => setPendingDMSwitch(null)} + > + 当前私信文案尚未发送,放弃后再切换吗? + +
+ ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 98df760..cc4e270 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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> = { 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 { + const auth = localStorage.getItem(AUTH_STORAGE_KEY); + const headers: Record = {}; + 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; + } + } +}