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:
2026-09-22 15:26:08 +08:00
parent 984a53f2ae
commit 3e87f67442
7 changed files with 1685 additions and 16 deletions
@@ -1,4 +1,2 @@
// TODO: 从 web.archived 迁移 creator/competitors/$id
export default function Page() {
return null;
}
// 与归档版路由一致:/creator/competitors/:id 复用竞品分析页面本身。
export { default } from '../index';
+411 -2
View File
@@ -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>
);
}
-4
View File
@@ -1,4 +0,0 @@
// TODO: 从 web.archived 迁移 creator/settings
export default function Page() {
return null;
}
+163
View File
@@ -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>
);
}
-4
View File
@@ -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
View File
@@ -1,6 +1,6 @@
// 领域 API 层:资源路径与语义逐一对照 web.archived/src/shared/api/dataProvider.js。
// 全部经由 requestErrorConfig.requestBasic 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.creatorSubscribefetch 流式读取,按 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;
}
}
}