baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
129
frontend/src/hooks/useSse.ts
Normal file
129
frontend/src/hooks/useSse.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
/**
|
||||
* useSse — SSE 客户端 React hook
|
||||
* 接收 SSE 事件,分派到 generationStore
|
||||
* event_seq 去重 + 断线重连UI
|
||||
* 五维分:SSE 推 total,五维详情通过 GET /tasks/{id} 补全
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createSseClient, SseClient, SseConnectionStatus } from '@/lib/sse';
|
||||
import { SseEvent } from '@/types/sse';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useSse(taskId: number | null) {
|
||||
const clientRef = useRef<SseClient | null>(null);
|
||||
const store = useGenerationStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
store.reset();
|
||||
|
||||
// reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。
|
||||
// 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成
|
||||
// reset 把 hydrate 数据清空的竞态(SPA 跳转进来会看到空白)。
|
||||
let cancelled = false;
|
||||
api.get<{
|
||||
text_candidates?: TextCandidate[];
|
||||
image_candidates?: ImageCandidate[];
|
||||
}>(`/api/v1/tasks/${taskId}`).then((task) => {
|
||||
if (cancelled) return;
|
||||
const texts = task.text_candidates ?? [];
|
||||
const images = task.image_candidates ?? [];
|
||||
if (texts.length || images.length) {
|
||||
store.hydrateFromTask({ textCandidates: texts, imageCandidates: images });
|
||||
}
|
||||
}).catch(() => { /* 进行中任务靠 SSE 填充 */ });
|
||||
|
||||
const client = createSseClient(taskId, {
|
||||
onEvent: (event: SseEvent) => handleEvent(event, store, taskId),
|
||||
onStatusChange: (status: SseConnectionStatus, attempt?: number) => {
|
||||
store.setConnectionStatus(status, attempt);
|
||||
},
|
||||
});
|
||||
clientRef.current = client;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
client.close();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
return {
|
||||
connectionStatus: store.connectionStatus,
|
||||
reconnectAttempt: store.reconnectAttempt,
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(
|
||||
event: SseEvent,
|
||||
store: ReturnType<typeof useGenerationStore.getState>,
|
||||
taskId: number,
|
||||
) {
|
||||
switch (event.type) {
|
||||
case 'task_started':
|
||||
store.setTextProgress(0, event.data.total_text);
|
||||
store.setImageProgress(0, event.data.total_image);
|
||||
break;
|
||||
|
||||
case 'text_progress':
|
||||
store.setTextProgress(event.data.done, event.data.total);
|
||||
break;
|
||||
|
||||
case 'text_candidate': {
|
||||
// SSE 只推 total 分;先用 total 占位,再异步补全五维分
|
||||
const totalScore = typeof event.data.score === 'number' ? event.data.score : 0;
|
||||
const tc: TextCandidate = {
|
||||
candidate_id: event.data.candidate_id,
|
||||
angle_label: event.data.angle_label,
|
||||
content: event.data.content,
|
||||
source: 'ai',
|
||||
score: { title: 0, emotion: 0, selling: 0, keyword: 0, compliance: 0, total: totalScore },
|
||||
banned_word_status: 'pass',
|
||||
};
|
||||
store.addTextCandidate(tc);
|
||||
// 异步补全五维分(不阻塞 SSE)
|
||||
api.get<{ text_candidates?: Array<{ candidate_id: number; score?: TextCandidate['score'] }> }>(
|
||||
`/api/v1/tasks/${taskId}`
|
||||
).then((task) => {
|
||||
const found = task.text_candidates?.find((c) => c.candidate_id === tc.candidate_id);
|
||||
if (found?.score) store.patchTextCandidateScore(tc.candidate_id, found.score);
|
||||
}).catch(() => { /* 五维分补全失败不影响主流程 */ });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'image_progress':
|
||||
store.setImageProgress(event.data.done, event.data.total);
|
||||
break;
|
||||
|
||||
case 'image_candidate': {
|
||||
const ic: ImageCandidate = {
|
||||
candidate_id: event.data.candidate_id,
|
||||
strategy: event.data.strategy,
|
||||
url: event.data.url,
|
||||
role: event.data.role as ImageCandidate['role'],
|
||||
};
|
||||
store.addImageCandidate(ic);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'batch_failed':
|
||||
store.markBatchFailed(event.data.batch, event.data.reason, event.data.retryable);
|
||||
break;
|
||||
|
||||
case 'flywheel_injected':
|
||||
store.setFlywheelInjected({
|
||||
recentPreference: event.data.recent_preference,
|
||||
rejectReasons: event.data.reject_reasons,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
case 'heartbeat':
|
||||
case 'analyze_done':
|
||||
case 'task_done':
|
||||
break;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user