baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

116
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* API 客户端 — 统一请求封装
* 含 JWT 注入 + 401 自动 token 刷新(扒 banana 范式,全新重建)
* 按契约§0 统一响应包络处理
*/
import { ApiError, ApiResponse } from '@/types/index';
import { ERROR_CODES } from '@/types/errors';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '';
// token 刷新锁,防并发重复刷新
let refreshPromise: Promise<string | null> | null = null;
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('clover_token');
}
function setToken(token: string): void {
localStorage.setItem('clover_token', token);
}
function clearToken(): void {
localStorage.removeItem('clover_token');
}
async function refreshToken(): Promise<string | null> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
// 走 /auth/refresh二期实现一期降级跳登录
clearToken();
window.location.href = '/login';
return null;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
async function request<T>(
path: string,
options: RequestInit = {},
isFormData = false,
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = isFormData
? {} // FormData: 不设 Content-Type让浏览器填 multipart boundary
: { 'Content-Type': 'application/json' };
Object.assign(headers, options.headers as Record<string, string>);
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
const body: ApiResponse<T> | ApiError = await res.json();
if ('code' in body && body.code === ERROR_CODES.SUCCESS) {
return (body as ApiResponse<T>).data;
}
const err = body as ApiError;
if (err.code === ERROR_CODES.UNAUTHENTICATED) {
const newToken = await refreshToken();
if (newToken) {
setToken(newToken);
return request<T>(path, options, isFormData);
}
}
throw err;
}
// ─── 公开方法 ────────────────────────────────────────────────
export const api = {
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
postForm: <T>(path: string, form: FormData) =>
request<T>(path, { method: 'POST', body: form }, true),
put: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
/**
* getBlob — 带 Authorization header 下载二进制文件
* 用于 C1 修复download-file 接口需要鉴权window.open 无法附带 token
*/
getBlob: async (path: string): Promise<Blob> => {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE_URL}${path}`, { method: 'GET', headers });
if (res.status === 401) {
await refreshToken();
throw { code: 40101, message: '认证失败,请重新登录' };
}
if (!res.ok) {
throw { code: 50001, message: `下载失败(${res.status})` };
}
return res.blob();
},
setToken,
getToken,
clearToken,
};

109
frontend/src/lib/sse.ts Normal file
View File

@@ -0,0 +1,109 @@
// SSE 客户端 — 按契约§2 + 安全红线(不传 JWT 进 URL先换 ticket 再建 EventSource
// 特性ticket 认证 + event_seq 去重 + 最多5次指数退避重连
// 注意BE 用 named eventsevent: text_candidate 等),必须用 addEventListener 而非 onmessage
import { SseEvent } from '@/types/sse';
import { api } from './api';
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
// 与 backend/app/utils/sse_utils.py SSE_EVENT_TYPES 保持同步
const SSE_EVENT_TYPES = [
'task_started', 'analyze_done', 'text_progress', 'text_candidate',
'image_progress', 'image_candidate', 'flywheel_injected',
'batch_failed', 'task_done', 'error', 'heartbeat',
] as const;
export type SseConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'closed';
export interface SseClientOptions {
onEvent: (event: SseEvent) => void;
onStatusChange: (status: SseConnectionStatus, attempt?: number) => void;
}
export class SseClient {
private taskId: number;
private options: SseClientOptions;
private es: EventSource | null = null;
private seenSeqs = new Set<number>();
private retryCount = 0;
private closed = false;
constructor(taskId: number, options: SseClientOptions) {
this.taskId = taskId;
this.options = options;
}
connect(lastSeq = 0): void {
if (this.closed) return;
this.options.onStatusChange(this.retryCount === 0 ? 'connecting' : 'reconnecting', this.retryCount);
this._fetchTicketAndOpen(lastSeq);
}
private async _fetchTicketAndOpen(lastSeq: number): Promise<void> {
if (this.closed) return;
try {
const { ticket } = await api.post<{ ticket: string; expires_in: number }>(
`/api/v1/tasks/${this.taskId}/sse-ticket`
);
if (!this.closed) this._openEventSource(ticket, lastSeq);
} catch {
if (!this.closed) this.scheduleRetry();
}
}
private _dispatch(e: MessageEvent): void {
// BE格式event:<type>\ndata:<json>\nid:<seq>e.type=named event type
try {
const data = JSON.parse(e.data);
const seq = e.lastEventId ? Number(e.lastEventId) : undefined;
if (seq !== undefined && !isNaN(seq)) {
if (this.seenSeqs.has(seq)) return;
this.seenSeqs.add(seq);
}
this.retryCount = 0;
// 将 named event type 合入 data构造 SseEvent
const sseEvent = { type: e.type, event_seq: seq ?? 0, data } as SseEvent;
this.options.onEvent(sseEvent);
} catch { /* 忽略解析错误 */ }
}
private _openEventSource(ticket: string, lastSeq: number): void {
// SSE 必须直连后端,绕过 Next dev rewrite 代理——后者会缓冲/吞掉 EventSource 的
// 流式事件(实测:经代理 EventSource 只触发 open零事件直连后端事件正常
// NEXT_PUBLIC_SSE_BASE_URL 缺省时退回 NEXT_PUBLIC_API_BASE_URL再退回相对路径。
const sseBase = (
process.env.NEXT_PUBLIC_SSE_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
''
).replace(/\/$/, '');
const url = `${sseBase}/api/v1/tasks/${this.taskId}/stream?ticket=${ticket}&last_seq=${lastSeq}`;
this.es = new EventSource(url);
this.es.onopen = () => this.options.onStatusChange('connected');
// 监听所有 named event typesonmessage 收不到 named events
for (const evType of SSE_EVENT_TYPES) {
this.es.addEventListener(evType, (e) => this._dispatch(e as MessageEvent));
}
this.es.onerror = () => {
this.es?.close(); this.es = null;
if (!this.closed) this.scheduleRetry();
};
}
private scheduleRetry(): void {
if (this.retryCount >= MAX_RETRIES) { this.options.onStatusChange('failed'); return; }
const delay = BASE_DELAY_MS * Math.pow(2, this.retryCount);
const lastSeq = this.seenSeqs.size > 0 ? Math.max(...this.seenSeqs) : 0;
this.retryCount++;
setTimeout(() => this.connect(lastSeq), delay);
}
close(): void {
this.closed = true; this.es?.close(); this.es = null;
this.options.onStatusChange('closed');
}
}
export function createSseClient(taskId: number, options: SseClientOptions): SseClient {
const client = new SseClient(taskId, options);
client.connect();
return client;
}