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

View File

@@ -0,0 +1,58 @@
'use client';
/**
* 屏1 配置台(管理员 /config
* 产品库 / 标杆笔记 / 违禁词 / API Key 录入
* 🔴 红线key 只录不显余额(不做 Token 用量展示)
*/
import { useState } from 'react';
import { ProductsTab } from '@/components/config/ProductsTab';
import { BenchmarksTab } from '@/components/config/BenchmarksTab';
import { BannedWordsTab } from '@/components/config/BannedWordsTab';
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
type TabKey = 'products' | 'benchmarks' | 'banned_words' | 'api_keys';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'products', label: '产品档案' },
{ key: 'benchmarks', label: '标杆笔记' },
{ key: 'banned_words', label: '违禁词库' },
{ key: 'api_keys', label: 'API Key' },
];
export default function ConfigPage() {
const [activeTab, setActiveTab] = useState<TabKey>('products');
return (
<div className="p-6 max-w-5xl">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
{/* Tab 导航 */}
<div className="flex gap-1 border-b border-border-default mb-6" role="tablist">
{TABS.map((tab) => (
<button
key={tab.key}
role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`panel-${tab.key}`}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
activeTab === tab.key
? 'bg-surface-primary border border-border-default border-b-white text-brand-orange'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab 内容 */}
<div id={`panel-${activeTab}`} role="tabpanel">
{activeTab === 'products' && <ProductsTab />}
{activeTab === 'benchmarks' && <BenchmarksTab />}
{activeTab === 'banned_words' && <BannedWordsTab />}
{activeTab === 'api_keys' && <ApiKeysTab />}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
/**
* /dashboard — 工作台(各角色登录后的中转落地页)
* 按角色展示对应入口
*/
import Link from 'next/link';
import { useAuthStore } from '@/stores/authStore';
export default function DashboardPage() {
const { user, role } = useAuthStore();
return (
<div className="p-6 max-w-3xl">
<h2 className="text-xl font-bold text-text-primary mb-2">
{user?.username ?? '朋友'}
</h2>
<p className="text-text-secondary text-sm mb-8">
{role === 'admin' && '你是管理员,可管理产品配置和审核'}
{role === 'supervisor' && '你是组长,负责审核待发笔记'}
{role === 'operator' && '今天要生成什么主题?'}
</p>
<div className="grid grid-cols-3 gap-4">
{role !== 'supervisor' && (
<DashCard href="/tasks/new" icon="✏️" label="开新任务" desc="选产品,输今天主题,开始生成" />
)}
{(role === 'supervisor' || role === 'admin') && (
<DashCard href="/review" icon="✅" label="审核台" desc="查看待审笔记,通过或打回" />
)}
{role === 'admin' && (
<DashCard href="/config" icon="⚙️" label="配置中心" desc="管理产品、标杆笔记、违禁词和 API Key" />
)}
<DashCard href="/history" icon="🗂️" label="历史归档" desc="查看历史任务和成品库" />
</div>
</div>
);
}
function DashCard({ href, icon, label, desc }: {
href: string; icon: string; label: string; desc: string;
}) {
return (
<Link href={href}
aria-label={label}
className="border border-border-default rounded-xl p-5 bg-surface-primary hover:border-brand-orange hover:shadow-sm transition-all group">
<span className="text-3xl block mb-2" aria-hidden="true">{icon}</span>
<h3 className="font-semibold text-text-primary text-sm group-hover:text-brand-orange">{label}</h3>
<p className="text-xs text-text-secondary mt-1">{desc}</p>
</Link>
);
}

View File

@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 最小宽度 1280px不做移动端 */
body {
min-width: 1280px;
}

View File

@@ -0,0 +1,174 @@
'use client';
/**
* HistoryTable / HistorySkeleton / Pagination / DownloadButton
* 历史归档页子组件(按约束拆出 ≤100行
*
* C1修复下载走 fetch+blob不用 window.open。
* window.open 无法附带 Authorization header会 401。
* 改法api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。
*/
import { useState } from 'react';
import Link from 'next/link';
import { clsx } from 'clsx';
import { TaskListItem } from '@/types';
import { api } from '@/lib/api';
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
approved: { text: '已通过', color: 'text-green-600 bg-green-50' },
archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' },
rejected: { text: '被打回', color: 'text-red-500 bg-red-50' },
};
// --- DownloadButton --- 触发打包→轮询→下载 zip
function DownloadButton({ taskId }: { taskId: number }) {
const [state, setState] = useState<'idle' | 'packaging' | 'done' | 'error'>('idle');
const [pkgId, setPkgId] = useState<number | null>(null);
async function handleDownload() {
setState('packaging');
try {
// 1. 触发打包
const pkg = await api.post<{ delivery_package_id: number }>(`/api/v1/tasks/${taskId}/package`, {});
const id = pkg.delivery_package_id;
setPkgId(id);
// 2. 轮询 status最多20次每2s
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 2000));
const info = await api.get<{ status: string }>(`/api/v1/delivery-packages/${id}/download`);
if (info.status === 'ready' || info.status === 'downloaded') {
// 3. fetch+blob 下载(带 Authorization header避免 window.open 401
const blob = await api.getBlob(`/api/v1/delivery-packages/${id}/download-file`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `delivery-${id}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setState('done');
return;
}
}
setState('error');
} catch { setState('error'); }
}
if (state === 'done') return <span className="text-xs text-green-600"></span>;
if (state === 'error') return <span className="text-xs text-red-500"></span>;
return (
<button
onClick={handleDownload}
disabled={state === 'packaging'}
className="text-xs text-brand-orange hover:underline disabled:text-text-tertiary"
>
{state === 'packaging' ? '打包中…' : '下载交付包'}
</button>
);
}
// --- HistoryTable ---
export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-border-default">
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-secondary text-text-secondary">
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{tasks.map((task, i) => (
<tr
key={task.id}
className={clsx(
'border-t border-border-default hover:bg-surface-tertiary transition-colors',
i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'
)}
>
<td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate">
{task.theme}
</td>
<td className="px-4 py-3">
<span className={clsx(
'px-2 py-0.5 rounded-full text-xs font-medium',
STATUS_LABEL[task.status]?.color ?? 'text-gray-500 bg-gray-100'
)}>
{STATUS_LABEL[task.status]?.text ?? task.status}
</span>
</td>
<td className="px-4 py-3 text-text-secondary">{task.text_count} </td>
<td className="px-4 py-3 text-text-secondary">{task.image_count} </td>
<td className="px-4 py-3 text-text-tertiary">
{new Date(task.created_at).toLocaleDateString('zh-CN')}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Link
href={`/tasks/${task.id}/confirm`}
className="text-brand-orange hover:underline text-xs"
>
</Link>
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// --- HistorySkeleton ---
export function HistorySkeleton() {
return (
<div className="rounded-lg border border-border-default overflow-hidden animate-pulse">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 border-t border-border-default first:border-t-0">
<div className="h-4 bg-gray-200 rounded flex-1" />
<div className="h-4 bg-gray-200 rounded w-16" />
<div className="h-4 bg-gray-200 rounded w-12" />
<div className="h-4 bg-gray-200 rounded w-12" />
<div className="h-4 bg-gray-200 rounded w-24" />
<div className="h-4 bg-gray-200 rounded w-8" />
</div>
))}
</div>
);
}
// --- Pagination ---
export function Pagination({
page, total, onChange,
}: { page: number; total: number; onChange: (p: number) => void }) {
return (
<div className="flex items-center justify-center gap-2" aria-label="分页">
<button
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="px-3 py-1 rounded border border-border-default text-sm disabled:opacity-40 hover:bg-surface-tertiary"
aria-label="上一页"
>
</button>
<span className="text-sm text-text-secondary px-2">
{page} / {total}
</span>
<button
disabled={page >= total}
onClick={() => onChange(page + 1)}
className="px-3 py-1 rounded border border-border-default text-sm disabled:opacity-40 hover:bg-surface-tertiary"
aria-label="下一页"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
/**
* 历史归档 /history
* 角色:熟手 + 组长(管理员也能进)
* 数据GET /api/v1/tasks?status=approved,archived,rejected
*/
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx';
import { HistoryTable, HistorySkeleton, Pagination } from './components';
const HISTORY_STATUSES = ['approved', 'archived', 'rejected'];
const STATUS_LABELS: Record<string, string> = {
approved: '已通过', archived: '已归档', rejected: '被打回',
};
const PAGE_SIZE = 20;
export default function HistoryPage() {
const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('all');
const fetchHistory = useCallback(async (p: number, status: string) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) });
if (status !== 'all') params.append('status', status);
else HISTORY_STATUSES.forEach((s) => params.append('status', s));
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
setTasks(res.items);
setTotal(res.total);
} catch (e: unknown) {
const ae = e as { code?: number };
setError(getErrorAction(ae.code ?? 50001).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchHistory(page, statusFilter); }, [page, statusFilter, fetchHistory]);
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-text-primary"></h1>
<span className="text-sm text-text-tertiary"> {total} </span>
</div>
{/* 状态筛选 */}
<div className="flex gap-2" role="group" aria-label="状态筛选">
{[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({
key: s, label: STATUS_LABELS[s],
}))].map((opt) => (
<button
key={opt.key}
onClick={() => { setStatusFilter(opt.key); setPage(1); }}
className={clsx(
'px-3 py-1 rounded-full text-sm border transition-colors',
statusFilter === opt.key
? 'bg-brand-orange text-white border-brand-orange'
: 'border-border-default text-text-secondary hover:border-brand-orange'
)}
>
{opt.label}
</button>
))}
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
<button className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter)}>
</button>
</div>
)}
{loading ? <HistorySkeleton /> : tasks.length === 0 ? (
<div className="py-20 text-center text-text-tertiary text-sm"></div>
) : (
<HistoryTable tasks={tasks} />
)}
{totalPages > 1 && <Pagination page={page} total={totalPages} onChange={setPage} />}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { Metadata } from 'next';
import './globals.css';
import { AuthGuard } from '@/components/AuthGuard';
import { Sidebar } from '@/components/layout/Sidebar';
export const metadata: Metadata = {
title: '龙石小红书内容生产平台',
description: '小红书内容生产工具',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="bg-surface-secondary text-text-primary">
<AuthGuard>
<div className="flex flex-col min-h-screen">
{/* 顶部 Header */}
<header className="h-14 bg-brand-orange flex items-center px-6 justify-between flex-shrink-0">
<h1 className="text-white font-bold text-base">
</h1>
<UserBadge />
</header>
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
</AuthGuard>
</body>
</html>
);
}
/** 右上角当前用户展示Server Component 不可用 StoreClient 组件内联) */
function UserBadge() {
// 通过 Client 组件获取 store 数据,此处为 Server Component 占位
return <UserBadgeClient />;
}
// 内联 Client 组件,避免额外文件
import UserBadgeClient from '@/components/layout/UserBadge';

View File

@@ -0,0 +1,86 @@
'use client';
// /login — 登录页按角色跳转首页admin→/config, supervisor→/review, operator→/tasks/new
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
import { api } from '@/lib/api';
import { LoginRequest, LoginResponse, ApiError } from '@/types/index';
import { getErrorAction } from '@/types/errors';
const INPUT_CLS = 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange transition-colors';
export default function LoginPage() {
const router = useRouter();
const { login, role } = useAuthStore();
const [form, setForm] = useState<LoginRequest>({ username: '', password: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.username || !form.password) {
setError('请填写用户名和密码');
return;
}
setLoading(true);
setError('');
try {
const data = await api.post<LoginResponse>('/api/v1/auth/login', form);
login(data.token, data.user);
// 按角色跳对应首页
const dest =
data.user.role === 'admin'
? '/config'
: data.user.role === 'supervisor'
? '/review'
: '/tasks/new';
router.replace(dest);
} catch (err) {
const apiErr = err as ApiError;
const action = getErrorAction(apiErr?.code);
setError(apiErr?.message || action.message);
} finally {
setLoading(false);
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-brand-cream">
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8">
<div className="text-center mb-8">
<div className="text-4xl mb-2" aria-hidden="true">🌿</div>
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-text-secondary text-sm mt-1"></p>
</div>
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="username" type="text" autoComplete="username" value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
className={INPUT_CLS} aria-required="true" />
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="password" type="password" autoComplete="current-password" value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className={INPUT_CLS} aria-required="true" />
</div>
{error && (
<div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg">
{error}
</div>
)}
<button type="submit" disabled={loading} aria-label="登录"
className="w-full bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50 transition-colors"
>
{loading ? '登录中…' : '登录'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { redirect } from 'next/navigation';
/**
* 根路由 / → 重定向到 /dashboard
* AuthGuard 会根据登录态再跳到对应首页
*/
export default function RootPage() {
redirect('/dashboard');
}

View File

@@ -0,0 +1,98 @@
'use client';
// 屏5 组长审核台:通过(+5) / 打回必填原因(-3)
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { ReviewQueueItem, RejectRequest, PaginatedResponse, ApiError } from '@/types/index';
import { RejectModal } from '@/components/review/RejectModal';
import { ReviewCard, ReviewSkeleton } from '@/components/review/ReviewCard';
import { getErrorAction } from '@/types/errors';
export default function ReviewPage() {
const [queue, setQueue] = useState<ReviewQueueItem[]>([]);
const [loading, setLoading] = useState(false);
const [rejectTarget, setRejectTarget] = useState<number | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
useEffect(() => { loadQueue(); }, []);
async function loadQueue() {
setLoading(true);
try {
const data = await api.get<PaginatedResponse<ReviewQueueItem>>('/api/v1/review/queue');
setQueue(data.items);
} finally {
setLoading(false);
}
}
function showToast(message: string, type: 'success' | 'error') {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
}
async function handleApprove(taskId: number) {
try {
await api.post(`/api/v1/review/${taskId}/approve`);
setQueue((q) => q.filter((item) => item.task_id !== taskId));
showToast('已通过,进入成品库', 'success');
} catch (err) {
const apiErr = err as ApiError;
showToast(apiErr?.message || getErrorAction(apiErr?.code).message, 'error');
}
}
async function handleReject(taskId: number, reason: string) {
const req: RejectRequest = { reason };
await api.post(`/api/v1/review/${taskId}/reject`, req);
setQueue((q) => q.filter((item) => item.task_id !== taskId));
showToast('已打回,任务回到挑选状态', 'success');
}
return (
<div className="p-6 max-w-4xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-text-primary">
{queue.length > 0 && <span className="ml-2 text-sm font-normal text-text-secondary">{queue.length}</span>}
</h2>
</div>
{/* Toast 提示 */}
{toast && (
<div role="alert" aria-live="assertive"
className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl shadow-lg text-sm text-white ${
toast.type === 'success' ? 'bg-status-success' : 'bg-status-error'
}`}>
{toast.message}
</div>
)}
{loading ? (
<ReviewSkeleton />
) : queue.length === 0 ? (
<div className="text-center py-16 text-text-tertiary">
<span className="text-4xl block mb-2" aria-hidden="true"></span>
</div>
) : (
<div className="space-y-4" aria-label="待审笔记列表">
{queue.map((item) => (
<ReviewCard key={item.task_id} item={item}
onApprove={() => handleApprove(item.task_id)}
onReject={() => setRejectTarget(item.task_id)}
/>
))}
</div>
)}
<div className="mt-8 text-sm text-text-secondary bg-surface-tertiary rounded-xl px-4 py-3">
++/
</div>
{rejectTarget !== null && (
<RejectModal taskId={rejectTarget}
onConfirm={(reason) => handleReject(rejectTarget, reason)}
onClose={() => setRejectTarget(null)} />
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
// 屏4.5 确认预览:提交审核 / 重新生成(飞轮-1
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
import { useTaskStore } from '@/stores/taskStore';
import { useGenerationStore } from '@/stores/generationStore';
import { api } from '@/lib/api';
import { useState } from 'react';
export default function ConfirmPage() {
const params = useParams<{ id: string }>();
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { selectedTextIds, selectedImageIds } = useTaskStore();
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
const { textCandidates, imageCandidates } = useGenerationStore();
const [submitting, setSubmitting] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id));
const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
async function handleSubmitReview() {
if (!taskId) return;
setSubmitting(true);
try {
await api.post(`/api/v1/tasks/${taskId}/submit-review`);
router.push('/review');
} finally {
setSubmitting(false);
}
}
async function handleRegenerate() {
if (!taskId) return;
setRegenerating(true);
try {
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
router.push(`/tasks/${taskId}/text`);
} finally {
setRegenerating(false);
}
}
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={4} />
<div className="p-6 flex-1 overflow-auto max-w-4xl">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
<div className="grid grid-cols-2 gap-6">
{/* 已选文案 */}
<div>
<h3 className="text-sm font-semibold text-text-secondary mb-3"></h3>
{selectedTexts.length === 0 ? (
<p className="text-text-tertiary text-sm"></p>
) : (
<div className="space-y-3">
{selectedTexts.map((c) => (
<div key={c.candidate_id}
className="border border-border-default rounded-xl p-4 bg-surface-primary">
<span className="text-xs text-brand-orange font-medium">{c.angle_label}</span>
<p className="text-sm text-text-primary mt-1 whitespace-pre-wrap line-clamp-6">
{c.content}
</p>
<span className="text-xs text-text-tertiary mt-1 block"> {c.score.total}</span>
</div>
))}
</div>
)}
</div>
{/* 已选图 */}
<ConfirmPreviewImages images={selectedImages} />
</div>
{/* 操作栏 */}
<ConfirmActionBar
submitting={submitting}
regenerating={regenerating}
canSubmit={selectedTextIds.length > 0}
onSubmit={handleSubmitReview}
onRegenerate={handleRegenerate}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
// 屏4 挑图0/N异步电影 + 平铺选图 + 单批重试(一期=整体重生) + 能离开
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { useSse } from '@/hooks/useSse';
import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore';
import { api } from '@/lib/api';
export default function ImageSelectionPage() {
const params = useParams<{ id: string }>();
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
const { selectedImageIds, toggleImageSelection } = useTaskStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId);
const pendingCount = imageTotal - imageDone;
const allDone = imageTotal > 0 && pendingCount === 0;
async function handleRetry(_batchRole: string) {
// 一期 regenerate = 整体重生,契约无 batch 字段,不发 body
// 二期按批重生时再扩展_batchRole 目前仅作入参占位UI 提示用)
if (!taskId) return;
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
}
async function handleProceedToConfirm() {
if (!taskId || selectedImageIds.length === 0) return;
for (const candidateId of selectedImageIds) {
await api.post(`/api/v1/tasks/${taskId}/image-candidates/select`, { candidate_id: candidateId });
}
router.push(`/tasks/${taskId}/confirm`);
}
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={3} />
{flywheelInjected && (
<FlywheelBannerFromSse
recentPreference={flywheelInjected.recentPreference}
rejectReasons={flywheelInjected.rejectReasons}
/>
)}
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
<div className="p-6 flex-1 overflow-auto">
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
{/* 图片网格(谁好谁先冒) */}
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
{imageCandidates.length === 0
? Array.from({ length: 5 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)
: imageCandidates.map((c) => (
<div key={c.candidate_id}
onClick={() => toggleImageSelection(c.candidate_id)}
className={`relative rounded-xl overflow-hidden border cursor-pointer transition-all
${selectedImageIds.includes(c.candidate_id) ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default hover:border-brand-orange/50'}`}
aria-label={`图片 ${c.role}${selectedImageIds.includes(c.candidate_id) ? '已选中' : '未选中'}`}
>
<img src={c.url} alt={c.role} className="w-full aspect-square object-cover" />
<p className="text-xs text-center py-1 text-text-secondary">{c.role}</p>
</div>
))}
</div>
{/* 失败批次提示 */}
{failedBatches.length > 0 && allDone && (
<div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error">
{failedBatches.length}
{failedBatches.filter((b) => b.retryable).map((b) => (
<button key={b.batch} onClick={() => handleRetry(b.batch)}
className="ml-2 underline" aria-label={`重试第${b.batch}`}> {b.batch}</button>
))}
</div>
)}
{imageCandidates.length > 0 && (
<ImageActionBar selectedCount={selectedImageIds.length} onProceed={handleProceedToConfirm} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
// 屏3 挑文案SSE实时+五维分+多选+飞轮隐形
import { useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { useSse } from '@/hooks/useSse';
import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore';
import { api } from '@/lib/api';
export default function TextSelectionPage() {
const params = useParams<{ id: string }>();
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { textCandidates, textDone, textTotal, flywheelInjected } = useGenerationStore();
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId);
useEffect(() => {
if (taskId) setCurrentTask(taskId, 'generating');
}, [taskId]);
async function handleProceedToImages() {
if (!taskId || selectedTextIds.length === 0) return;
for (const candidateId of selectedTextIds) {
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
}
router.push(`/tasks/${taskId}/images`);
}
const isGenerating = textDone < textTotal || textTotal === 0;
const showSkeleton = textCandidates.length === 0 && isGenerating;
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={2} />
{flywheelInjected && (
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
)}
<div className="p-6 flex-1 overflow-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-text-primary">
{textTotal > 0 && <span className="text-sm font-normal text-text-secondary ml-2"> {textDone}/{textTotal} </span>}
</h2>
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
</div>
{/* SSE 进度提示(生成中且无内容时) */}
{showSkeleton && (
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
role="status" aria-live="polite">
15-30
</div>
)}
{/* 文案卡网格(谁好谁先冒) */}
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4">
{showSkeleton
? Array.from({ length: 4 }).map((_, i) => <TextCandidateCardSkeleton key={i} />)
: textCandidates.map((c) => (
<TextCandidateCard
key={c.candidate_id}
candidate={c}
selected={selectedTextIds.includes(c.candidate_id)}
onToggle={toggleTextSelection}
/>
))}
</div>
{/* 底部操作栏 */}
{textCandidates.length > 0 && (
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
<span className="text-sm text-text-secondary">
<strong className="text-text-primary">{selectedTextIds.length}</strong>
</span>
<button
onClick={handleProceedToImages}
disabled={selectedTextIds.length === 0}
aria-label={`用选中的 ${selectedTextIds.length} 条文案去生图`}
className="bg-brand-orange text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
>
{selectedTextIds.length}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
/**
* 屏2 开新任务(熟手 /tasks/new
* 使用 NewTaskForm + ConfigPreviewPanel + RecentTasksBar + FlywheelBanner
*/
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBanner } from '@/components/FlywheelBanner';
import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel';
import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
import { api } from '@/lib/api';
import { Product, CreateTaskRequest } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { usePreferenceStore } from '@/stores/preferenceStore';
import { getErrorAction } from '@/types/errors';
import { ApiError } from '@/types/index';
export default function NewTaskPage() {
const router = useRouter();
const { context } = usePreferenceStore();
const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({
benchmark_ids: [],
theme: '',
text_count: 8,
image_count: 6,
track: 'ai',
need_product_image: true,
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => { loadProducts(); }, []);
async function loadProducts() {
try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
setProducts(data.items);
if (data.items.length > 0) setSelectedProduct(data.items[0]);
} catch {
// ignore
}
}
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
e.preventDefault();
if (!selectedProduct) { setError('请选择产品'); return; }
if (!form.theme.trim()) { setError('请填写今天主题'); return; }
// 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传
if (form.need_product_image && !selectedProduct.image_path) {
setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。');
return;
}
setSubmitting(true);
setError('');
try {
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track };
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
router.push(`/tasks/${data.id}/text`);
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || getErrorAction(apiErr?.code).message);
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={1} />
{context && <FlywheelBanner context={context} />}
<div className="flex flex-1 overflow-hidden">
{/* 主表单区 */}
<div className="flex-1 p-6 overflow-auto">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
<NewTaskForm
products={products}
selectedProduct={selectedProduct}
onSelectProduct={setSelectedProduct}
form={form}
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
submitting={submitting}
error={error}
onSubmitAi={(e) => handleSubmit(e, 'ai')}
onSubmitImport={(e) => handleSubmit(e, 'import')}
/>
<div className="mt-8"><RecentTasksBar /></div>
</div>
{/* 右侧配置预览面板 */}
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
/**
* AuthGuard — 路由守卫
* - 未登录 → /login
* - 越权(组长访问 /config非管理员等→ /dashboard
* 扒 banana AuthGuard 范式,全新重建
*/
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
import { UserRole } from '@/types/index';
// 路由 → 最低所需角色
const ROUTE_ROLES: Record<string, UserRole[]> = {
'/config': ['admin'],
'/review': ['admin', 'supervisor'],
'/history': ['admin', 'supervisor', 'operator'],
'/tasks/new': ['admin', 'operator'],
'/dashboard': ['admin', 'supervisor', 'operator'],
};
function getRequiredRoles(pathname: string): UserRole[] | null {
// 精确匹配或前缀匹配(如 /tasks/[id]/text
if (ROUTE_ROLES[pathname]) return ROUTE_ROLES[pathname];
for (const [prefix, roles] of Object.entries(ROUTE_ROLES)) {
if (pathname.startsWith(prefix + '/')) return roles;
}
return null; // 公开路由(/login 等)
}
interface AuthGuardProps {
children: React.ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, role, isLoading, fetchMe, endLoading } = useAuthStore();
useEffect(() => {
// 首屏校验:有 token 且 store 未初始化 → 拉 me无 token → 结束 loading 让守卫放行判断
const token = typeof window !== 'undefined' ? localStorage.getItem('clover_token') : null;
if (token && !isAuthenticated && isLoading) {
fetchMe();
} else if (!token && isLoading) {
endLoading();
}
}, [isAuthenticated, isLoading, fetchMe, endLoading]);
useEffect(() => {
if (isLoading) return;
// 公开路由直接放行
if (pathname === '/login') return;
if (!isAuthenticated) {
router.replace('/login');
return;
}
const required = getRequiredRoles(pathname);
if (required && role && !required.includes(role)) {
router.replace('/dashboard');
}
}, [isAuthenticated, role, isLoading, pathname, router]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-surface-secondary">
<div className="text-text-secondary text-sm"></div>
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,65 @@
'use client';
/**
* FlywheelBanner — 飞轮注入提示横幅
* 飞轮隐形:无"训练AI"按钮,只在生成时显示"本次已注入"
* 对应 flywheel_injected SSE 事件 + GET /preference/context
*/
import { PreferenceContext } from '@/types/index';
interface FlywheelBannerProps {
context: PreferenceContext;
}
export function FlywheelBanner({ context }: FlywheelBannerProps) {
if (!context.injected_count && !context.recent_preference) return null;
return (
<div
role="status"
aria-live="polite"
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex items-start gap-2"
>
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<div>
<span className="font-medium"></span>
{context.recent_preference && (
<span>{context.recent_preference}</span>
)}
{context.reject_reasons?.length > 0 && (
<span className="ml-2 text-text-secondary">
· {context.reject_reasons.slice(0, 2).join('')}
</span>
)}
</div>
</div>
);
}
/**
* FlywheelBannerFromSse — SSE flywheel_injected 事件版本
*/
interface FlywheelBannerFromSseProps {
recentPreference: string;
rejectReasons: string[];
}
export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: FlywheelBannerFromSseProps) {
return (
<div
role="status"
aria-live="polite"
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex items-start gap-2"
>
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<div>
<span className="font-medium"></span>
{recentPreference && <span>{recentPreference}</span>}
{rejectReasons?.length > 0 && (
<span className="ml-2 text-text-secondary">
· {rejectReasons.slice(0, 2).join('')}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
// ApiKeysTab — API Key 录入只录不显余额CLAUDE.md红线
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { ApiKey, CreateApiKeyRequest, PaginatedResponse, ApiError } from '@/types/index';
import { getErrorAction } from '@/types/errors';
export function ApiKeysTab() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'openai', api_key: '' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => { loadKeys(); }, []);
async function loadKeys() {
setLoading(true);
try {
const data = await api.get<PaginatedResponse<ApiKey>>('/api/v1/api-keys');
setKeys(data.items);
} catch { /* silent */ } finally { setLoading(false); }
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
setSaving(true); setError('');
try {
await api.post('/api/v1/api-keys', form);
setForm({ provider: 'openai', api_key: '' });
await loadKeys();
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || getErrorAction(apiErr?.code).message);
} finally { setSaving(false); }
}
async function handleDelete(id: number) {
if (!confirm('确认删除此 Key')) return;
try {
await api.delete(`/api/v1/api-keys/${id}`);
setKeys((ks) => ks.filter((k) => k.id !== id));
} catch { /* ignore */ }
}
return (
<div className="space-y-6">
<p className="text-sm text-text-secondary bg-brand-cream-dark px-4 py-3 rounded-lg">
API Key
</p>
<form onSubmit={handleAdd} className="flex gap-3 items-end">
<div>
<label htmlFor="provider" className="block text-sm font-medium text-text-primary mb-1">Provider</label>
<select id="provider" value={form.provider}
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value }))}
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange">
<option value="openai">OpenAI (gpt-image-2)</option>
<option value="gemini">Gemini</option>
</select>
</div>
<div className="flex-1">
<label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">API Key</label>
<input id="api-key-input" type="password" value={form.api_key}
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
placeholder="sk-..."
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
aria-label="输入 API Key"
/>
</div>
<button type="submit" disabled={saving} aria-label="保存 API Key"
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
{saving ? '保存中…' : '保存'}
</button>
</form>
{error && <p role="alert" className="text-status-error text-sm">{error}</p>}
{loading ? (
<div className="text-text-secondary text-sm"></div>
) : (
<ul className="divide-y divide-border-default" aria-label="已录入的 API Key 列表">
{keys.map((k) => (
<li key={k.id} className="flex items-center justify-between py-3 text-sm">
<span>
<span className="font-medium">{k.provider}</span>
<span className="text-text-secondary ml-2">{k.key_last4}</span>
</span>
<button onClick={() => handleDelete(k.id)} aria-label={`删除 ${k.provider} Key`}
className="text-status-error text-xs hover:underline"></button>
</li>
))}
{keys.length === 0 && (
<li className="py-3 text-text-tertiary text-sm"> Key</li>
)}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
/**
* BannedWordsTab — 违禁词库管理(三级可调)
*/
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { BannedWord, CreateBannedWordRequest, BannedWordLevel } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { BannedWordsTable } from './BannedWordsTable';
export function BannedWordsTab() {
const [words, setWords] = useState<BannedWord[]>([]);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState<CreateBannedWordRequest>({
word: '', level: 'soft_warn', replacement: null, updatable: true,
});
const [saving, setSaving] = useState(false);
useEffect(() => { loadWords(); }, []);
async function loadWords() {
setLoading(true);
try {
const data = await api.get<PaginatedResponse<BannedWord>>('/api/v1/banned-words');
setWords(data.items);
} finally { setLoading(false); }
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!form.word.trim()) return;
setSaving(true);
try {
await api.post('/api/v1/banned-words', form);
setForm({ word: '', level: 'soft_warn', replacement: null, updatable: true });
await loadWords();
} finally { setSaving(false); }
}
async function handleDelete(id: number) {
if (!confirm('确认删除此违禁词?')) return;
await api.delete(`/api/v1/banned-words/${id}`);
setWords((ws) => ws.filter((w) => w.id !== id));
}
return (
<div className="space-y-6">
<p className="text-sm text-text-secondary">
/ /
</p>
<form onSubmit={handleAdd} className="flex gap-3 items-end flex-wrap">
<div>
<label htmlFor="bw-word" className="block text-sm font-medium mb-1"></label>
<input id="bw-word" value={form.word}
onChange={(e) => setForm((f) => ({ ...f, word: e.target.value }))}
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange w-32"
aria-required="true"
/>
</div>
<div>
<label htmlFor="bw-level" className="block text-sm font-medium mb-1"></label>
<select id="bw-level" value={form.level}
onChange={(e) => setForm((f) => ({ ...f, level: e.target.value as BannedWordLevel }))}
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange">
<option value="auto_fix"></option>
<option value="soft_warn"></option>
<option value="hard_block"></option>
</select>
</div>
<div>
<label htmlFor="bw-replace" className="block text-sm font-medium mb-1"></label>
<input id="bw-replace" value={form.replacement ?? ''}
onChange={(e) => setForm((f) => ({ ...f, replacement: e.target.value || null }))}
placeholder="如:提亮"
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange w-28"
/>
</div>
<button type="submit" disabled={saving} aria-label="添加违禁词"
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
{saving ? '添加中…' : '添加'}
</button>
</form>
{loading ? (
<div className="text-text-secondary text-sm" aria-busy="true"></div>
) : (
<BannedWordsTable words={words} onDelete={handleDelete} />
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
/**
* BannedWordsTable — 违禁词列表表格
*/
import { BannedWord, BannedWordLevel } from '@/types/index';
const LEVEL_LABELS: Record<BannedWordLevel, { label: string; color: string }> = {
auto_fix: { label: '🟢 自动改写', color: 'text-status-success bg-green-50' },
soft_warn: { label: '🟡 软提示', color: 'text-status-warning bg-yellow-50' },
hard_block: { label: '🔴 硬拦截', color: 'text-status-error bg-red-50' },
};
interface BannedWordsTableProps {
words: BannedWord[];
onDelete: (id: number) => void;
}
export function BannedWordsTable({ words, onDelete }: BannedWordsTableProps) {
return (
<table className="w-full text-sm" aria-label="违禁词列表">
<thead>
<tr className="text-text-secondary border-b border-border-default text-left">
<th className="py-2 pr-4 font-medium"></th>
<th className="py-2 pr-4 font-medium"></th>
<th className="py-2 pr-4 font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border-default">
{words.map((w) => (
<tr key={w.id}>
<td className="py-2 pr-4">{w.word}</td>
<td className="py-2 pr-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${LEVEL_LABELS[w.level].color}`}>
{LEVEL_LABELS[w.level].label}
</span>
</td>
<td className="py-2 pr-4 text-text-secondary">{w.replacement ?? '—'}</td>
<td className="py-2">
{w.updatable && (
<button onClick={() => onDelete(w.id)}
aria-label={`删除违禁词 ${w.word}`}
className="text-status-error text-xs hover:underline"></button>
)}
</td>
</tr>
))}
{words.length === 0 && (
<tr><td colSpan={4} className="py-4 text-text-tertiary text-center"></td></tr>
)}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
/**
* BenchmarkForm — 标杆笔记上传表单
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { CreateBenchmarkRequest } from '@/types/index';
interface BenchmarkFormProps {
productId: number;
onSaved: () => void;
onCancel: () => void;
}
export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormProps) {
const [form, setForm] = useState<CreateBenchmarkRequest>({
screenshot_url: '', highlights: '', link_url: null,
});
const [saving, setSaving] = useState(false);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.screenshot_url || !form.highlights) return;
setSaving(true);
try {
await api.post(`/api/v1/products/${productId}/benchmarks`, form);
onSaved();
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSave}
className="border border-border-default rounded-xl p-6 bg-surface-primary space-y-4">
<h3 className="font-medium text-text-primary"></h3>
<div>
<label htmlFor="bm-url" className="block text-sm font-medium mb-1"> URL</label>
<input id="bm-url" value={form.screenshot_url}
onChange={(e) => setForm((f) => ({ ...f, screenshot_url: e.target.value }))}
placeholder="https://..."
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
required aria-required="true"
/>
</div>
<div>
<label htmlFor="bm-highlights" className="block text-sm font-medium mb-1"></label>
<textarea id="bm-highlights" value={form.highlights} rows={3}
onChange={(e) => setForm((f) => ({ ...f, highlights: e.target.value }))}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange resize-none"
required aria-required="true"
/>
</div>
<div className="flex gap-3 justify-end">
<button type="button" onClick={onCancel}
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
</button>
<button type="submit" disabled={saving}
className="px-4 py-2 text-sm bg-brand-orange text-white rounded-lg hover:bg-orange-600 disabled:opacity-50">
{saving ? '保存中…' : '保存'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
/**
* BenchmarksTab — 标杆笔记管理(使用 BenchmarkForm 子组件)
*/
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Benchmark, Product } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { BenchmarkForm } from './BenchmarkForm';
export function BenchmarksTab() {
const [products, setProducts] = useState<Product[]>([]);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => { loadProducts(); }, []);
async function loadProducts() {
try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
setProducts(data.items);
if (data.items.length > 0) setSelectedProductId(data.items[0].id);
} catch { /* ignore */ }
}
useEffect(() => {
if (!selectedProductId) return;
loadBenchmarks(selectedProductId);
}, [selectedProductId]);
async function loadBenchmarks(productId: number) {
setLoading(true);
try {
const data = await api.get<PaginatedResponse<Benchmark>>(
`/api/v1/products/${productId}/benchmarks`
);
setBenchmarks(data.items);
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<label htmlFor="benchmark-product" className="text-sm font-medium text-text-primary">
</label>
<select id="benchmark-product"
value={selectedProductId ?? ''}
onChange={(e) => setSelectedProductId(Number(e.target.value))}
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
>
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<button onClick={() => setShowForm(true)} aria-label="添加标杆笔记"
className="bg-brand-orange text-white rounded-lg px-3 py-2 text-sm hover:bg-orange-600">
+
</button>
</div>
{loading ? (
<div className="text-text-secondary text-sm" aria-busy="true"></div>
) : (
<div className="grid grid-cols-3 gap-4">
{benchmarks.map((b) => (
<div key={b.id} className="border border-border-default rounded-xl overflow-hidden bg-surface-primary">
<img src={b.screenshot_url} alt="标杆笔记截图"
className="w-full h-32 object-cover bg-surface-tertiary" />
<div className="p-3">
<p className="text-xs text-text-secondary line-clamp-2">{b.highlights}</p>
{b.link_url && (
<a href={b.link_url} target="_blank" rel="noopener noreferrer"
className="text-xs text-brand-orange hover:underline mt-1 block"></a>
)}
</div>
</div>
))}
{benchmarks.length === 0 && (
<p className="col-span-3 text-text-tertiary text-sm py-4 text-center">
"传成品包"
</p>
)}
</div>
)}
{showForm && selectedProductId && (
<BenchmarkForm
productId={selectedProductId}
onSaved={() => { setShowForm(false); loadBenchmarks(selectedProductId!); }}
onCancel={() => setShowForm(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,181 @@
'use client';
/**
* ProductCard + ProductForm + ProductsSkeleton + ProductImageUpload — 产品档案子组件
*/
import { useRef, useState } from 'react';
import { api } from '@/lib/api';
import { Product, CreateProductRequest } from '@/types/index';
// --- ProductImageUpload ---
export function ProductImageUpload({
product,
onUploaded,
}: {
product: Product;
onUploaded: (updated: Product) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) { setError('文件超过 10 MB'); return; }
setError('');
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
const updated = await api.postForm<Product>(
`/api/v1/products/${product.id}/upload-image`, form
);
onUploaded(updated);
} catch {
setError('上传失败,请重试');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className="mt-2">
{product.image_path ? (
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/uploads/${product.image_path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`}
alt={`${product.name}参考图`}
className="h-12 w-12 object-cover rounded border border-border-default"
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-xs text-brand-orange hover:underline"
>
</button>
</div>
) : (
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="text-xs border border-dashed border-border-default rounded px-3 py-1
text-text-secondary hover:border-brand-orange hover:text-brand-orange
disabled:opacity-50 transition-colors"
>
{uploading ? '上传中…' : '+ 上传产品参考图'}
</button>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
aria-label="选择产品参考图"
onChange={handleFile}
/>
</div>
);
}
export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) {
const [current, setCurrent] = useState<Product>(product);
return (
<div className="border border-border-default rounded-xl p-4 bg-surface-primary space-y-2">
<div className="flex items-center justify-between">
<h3 className="font-medium text-text-primary">{current.name}</h3>
<span className="text-xs text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{current.category}
</span>
</div>
<p className="text-xs text-text-secondary line-clamp-2">
{current.selling_points.join(' · ')}
</p>
<div className="flex flex-wrap gap-1">
{current.text_angles.slice(0, 3).map((a) => (
<span key={a} className="text-xs bg-brand-cream px-2 py-0.5 rounded-full text-text-secondary">
{a}
</span>
))}
</div>
<ProductImageUpload
product={current}
onUploaded={(updated) => { setCurrent(updated); onUpdated(); }}
/>
</div>
);
}
export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCancel: () => void }) {
const [form, setForm] = useState<CreateProductRequest>({
name: '', category: '', source: 'custom',
selling_points: [], style_tone: '', text_angles: [],
custom_prompt: null, banned_word_ids: [],
});
const [saving, setSaving] = useState(false);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.name) return;
setSaving(true);
try {
await api.post('/api/v1/products', form);
onSaved();
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSave}
className="border border-border-default rounded-xl p-6 bg-surface-primary space-y-4">
<h3 className="font-medium text-text-primary"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="prod-name" className="block text-sm font-medium mb-1"></label>
<input id="prod-name" value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
required aria-required="true"
/>
</div>
<div>
<label htmlFor="prod-category" className="block text-sm font-medium mb-1"></label>
<input id="prod-category" value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
/>
</div>
</div>
<div className="flex gap-3 justify-end">
<button type="button" onClick={onCancel}
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
</button>
<button type="submit" disabled={saving}
className="px-4 py-2 text-sm bg-brand-orange text-white rounded-lg hover:bg-orange-600 disabled:opacity-50">
{saving ? '保存中…' : '保存'}
</button>
</div>
</form>
);
}
export function ProductsSkeleton() {
return (
<div className="grid grid-cols-3 gap-4" aria-busy="true" aria-label="加载产品档案中">
{[1, 2, 3].map((i) => (
<div key={i} className="border border-border-default rounded-xl p-4 animate-pulse space-y-2">
<div className="h-4 bg-surface-tertiary rounded w-1/2" />
<div className="h-3 bg-surface-tertiary rounded w-full" />
<div className="h-3 bg-surface-tertiary rounded w-3/4" />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
/**
* ProductsTab — 产品档案管理
* 品类不写死基石A客户自助加
*/
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Product } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { ProductCard, ProductForm, ProductsSkeleton } from './ProductSubComponents';
export function ProductsTab() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
useEffect(() => { loadProducts(); }, []);
async function loadProducts() {
setLoading(true);
try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
setProducts(data.items);
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
{loading ? (
<ProductsSkeleton />
) : (
<>
{/* 内置产品库preset平台预置带参考图开箱即用 */}
{products.some((p) => p.source === 'preset') && (
<section>
<h3 className="text-sm font-semibold text-text-secondary mb-3">
<span className="font-normal text-text-tertiary"></span>
</h3>
<div className="grid grid-cols-3 gap-4">
{products.filter((p) => p.source === 'preset').map((p) => (
<ProductCard key={p.id} product={p} onUpdated={loadProducts} />
))}
</div>
</section>
)}
{/* 我的产品custom自建+上传参考图) */}
<section>
<h3 className="text-sm font-semibold text-text-secondary mb-3">
<span className="font-normal text-text-tertiary"></span>
</h3>
<div className="grid grid-cols-3 gap-4">
{products.filter((p) => p.source !== 'preset').map((p) => (
<ProductCard key={p.id} product={p} onUpdated={loadProducts} />
))}
<button
onClick={() => setShowForm(true)}
aria-label="添加新产品"
className="border-2 border-dashed border-border-default rounded-xl p-6 flex flex-col items-center
justify-center gap-2 text-text-secondary hover:border-brand-orange hover:text-brand-orange transition-colors"
>
<span className="text-2xl" aria-hidden="true">+</span>
<span className="text-sm"></span>
</button>
</div>
</section>
</>
)}
{showForm && (
<ProductForm
onSaved={() => { setShowForm(false); loadProducts(); }}
onCancel={() => setShowForm(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
/**
* ProgressBar — 5步流程条熟手常驻
* 步骤1开新任务/2选择文案/3选择图片/4确认文案/5成片发布
* 组长/管理员不显示此条
*/
import { useAuthStore } from '@/stores/authStore';
import { clsx } from 'clsx';
const STEPS = [
{ index: 1, label: '开新任务', href: '/tasks/new' },
{ index: 2, label: '选择文案', href: '/tasks' },
{ index: 3, label: '选择图片', href: '/images' },
{ index: 4, label: '确认文案', href: '/confirm' },
{ index: 5, label: '成片发布', href: '/history' },
];
interface ProgressBarProps {
currentStep: number;
}
export function ProgressBar({ currentStep }: ProgressBarProps) {
const { role } = useAuthStore();
// 组长/管理员不走流程条
if (role === 'supervisor' || role === 'admin') return null;
return (
<div
className="flex items-center justify-center gap-0 bg-brand-cream border-b border-border-default px-6 py-3"
aria-label="任务流程进度"
role="navigation"
>
{STEPS.map((step, idx) => {
const done = step.index < currentStep;
const active = step.index === currentStep;
return (
<div key={step.index} className="flex items-center">
<div className="flex flex-col items-center gap-1">
<div
className={clsx(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold',
done && 'bg-brand-green text-white',
active && 'bg-brand-orange text-white',
!done && !active && 'bg-surface-tertiary text-text-tertiary'
)}
aria-current={active ? 'step' : undefined}
>
{done ? '✓' : step.index}
</div>
<span
className={clsx(
'text-xs whitespace-nowrap',
active ? 'text-brand-orange font-medium' : 'text-text-secondary'
)}
>
{step.label}
</span>
</div>
{idx < STEPS.length - 1 && (
<div
className={clsx(
'w-10 h-0.5 mx-2 mb-4',
done ? 'bg-brand-green' : 'bg-border-default'
)}
aria-hidden="true"
/>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
/**
* Sidebar — 左侧8项导航
* 按 PRD-前端§2 全局布局
* 角色守卫:/review 只组长+管理员;/config 只管理员
*/
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
import { clsx } from 'clsx';
interface NavItem {
label: string;
href: string;
roles?: string[];
icon: string;
}
const NAV_ITEMS: NavItem[] = [
{ label: '工作台', href: '/dashboard', icon: '🏠' },
{ label: '开新任务', href: '/tasks/new', icon: '✏️' },
{ label: '选择文案', href: '/tasks', icon: '📝' },
{ label: '选择图片', href: '/images', icon: '🖼️' },
{ label: '建立任务', href: '/tasks/build', icon: '⚙️' },
{ label: '任务列表', href: '/tasks/list', icon: '📋' },
{ label: '历史归档', href: '/history', icon: '🗂️' },
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' },
];
export function Sidebar() {
const pathname = usePathname();
const { role } = useAuthStore();
const visible = NAV_ITEMS.filter(
(item) => !item.roles || (role && item.roles.includes(role))
);
return (
<nav
className="w-48 bg-surface-primary border-r border-border-default flex flex-col py-4 gap-1"
aria-label="主导航"
>
{visible.map((item) => {
const active = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
className={clsx(
'flex items-center gap-2 px-4 py-2 text-sm rounded-md mx-2 transition-colors',
active
? 'bg-brand-orange-light text-brand-orange font-medium'
: 'text-text-secondary hover:bg-surface-tertiary hover:text-text-primary'
)}
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
</Link>
);
})}
{/* 版本号 — 替代原Token余额位置 */}
<div className="mt-auto px-4 py-3 text-xs text-text-tertiary">
Clover v0.1.0
<br />
<span aria-hidden="true">🌿</span>
</div>
</nav>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
/**
* UserBadge — 顶部右上角当前用户
* 只显示名称和角色,不显示 token 余额
*/
import { useAuthStore } from '@/stores/authStore';
import { useRouter } from 'next/navigation';
const ROLE_LABELS: Record<string, string> = {
admin: '管理员',
supervisor: '组长',
operator: '运营',
};
export default function UserBadge() {
const { user, role, logout } = useAuthStore();
const router = useRouter();
if (!user) return null;
function handleLogout() {
logout();
router.replace('/login');
}
return (
<div className="flex items-center gap-3">
<span className="text-white text-sm">
{role ? ROLE_LABELS[role] : ''} · {user.username}
</span>
<button
onClick={handleLogout}
aria-label="退出登录"
className="text-white/80 text-xs hover:text-white transition-colors"
>
退
</button>
</div>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
/**
* RejectModal — 打回原因模态框(必填校验)
* 飞轮最强信号:组长打回必填原因,下次自动注入 prompt
*/
import { useState } from 'react';
interface RejectModalProps {
taskId: number;
onConfirm: (reason: string) => Promise<void>;
onClose: () => void;
}
export function RejectModal({ taskId, onConfirm, onClose }: RejectModalProps) {
const [reason, setReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!reason.trim()) {
setError('打回原因必填,下次 AI 生成时会自动注入此原因避免同类问题');
return;
}
setSubmitting(true);
try {
await onConfirm(reason.trim());
onClose();
} finally {
setSubmitting(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
role="dialog"
aria-modal="true"
aria-labelledby="reject-modal-title"
>
<div className="bg-surface-primary rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<h2 id="reject-modal-title" className="text-base font-bold text-text-primary mb-1">
</h2>
<p className="text-xs text-text-secondary mb-4">
AI
</p>
<form onSubmit={handleSubmit} noValidate>
<textarea
value={reason}
onChange={(e) => { setReason(e.target.value); setError(''); }}
placeholder="如:标题太硬广,不像素人分享"
rows={4}
aria-required="true"
aria-label="打回原因(必填)"
autoFocus
className="w-full border border-border-default rounded-xl px-3 py-2 text-sm resize-none
focus:outline-none focus:border-brand-orange"
/>
{error && (
<p role="alert" className="text-status-error text-xs mt-1">{error}</p>
)}
<div className="flex gap-3 mt-4 justify-end">
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
</button>
<button type="submit" disabled={submitting}
aria-label="确认打回"
className="px-4 py-2 text-sm bg-status-error text-white rounded-lg hover:bg-red-600 disabled:opacity-50">
{submitting ? '提交中…' : '确认打回'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
/**
* ReviewCard — 单条待审笔记卡
* 通过(+5) / 打回(-3必填原因)
*/
import { ReviewQueueItem } from '@/types/index';
interface ReviewCardProps {
item: ReviewQueueItem;
onApprove: () => void;
onReject: () => void;
}
export function ReviewCard({ item, onApprove, onReject }: ReviewCardProps) {
return (
<article
className="border border-border-default rounded-xl bg-surface-primary p-4"
aria-label={`待审笔记:${item.theme}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-text-secondary">{item.product_name}</span>
<span className="text-xs text-text-tertiary">·</span>
<span className="text-xs text-text-secondary">{item.operator_name}</span>
</div>
<h3 className="font-medium text-text-primary text-sm line-clamp-1">{item.theme}</h3>
{item.selected_text && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">
{item.selected_text.content}
</p>
)}
</div>
{item.selected_image && (
<img src={item.selected_image.url} alt="已选图预览"
className="w-16 h-16 rounded-lg object-cover flex-shrink-0" />
)}
</div>
{/* 操作栏 — 飞轮最强信号 */}
<div className="flex gap-3 mt-4 pt-4 border-t border-border-default">
<button onClick={onApprove}
aria-label={`通过笔记:${item.theme}`}
className="flex-1 bg-brand-green text-white rounded-lg py-2 text-sm font-medium hover:bg-green-600">
</button>
<button onClick={onReject}
aria-label={`打回笔记:${item.theme}`}
className="flex-1 bg-surface-tertiary text-status-error border border-status-error rounded-lg py-2 text-sm font-medium hover:bg-red-50">
+
</button>
</div>
</article>
);
}
/** 审核列表骨架屏 */
export function ReviewSkeleton() {
return (
<div className="space-y-4" aria-busy="true" aria-label="加载待审队列中">
{[1, 2, 3].map((i) => (
<div key={i} className="border border-border-default rounded-xl p-4 animate-pulse space-y-3">
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<div className="h-3 bg-surface-tertiary rounded w-1/3" />
<div className="h-4 bg-surface-tertiary rounded w-1/2" />
<div className="h-3 bg-surface-tertiary rounded w-3/4" />
</div>
<div className="w-16 h-16 bg-surface-tertiary rounded-lg flex-shrink-0" />
</div>
<div className="flex gap-3 pt-3 border-t border-border-default">
<div className="flex-1 h-8 bg-surface-tertiary rounded-lg" />
<div className="flex-1 h-8 bg-surface-tertiary rounded-lg" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
/**
* ConfigPreviewPanel — 右侧配置预览面板
* 显示产品名称/卖点/成分/质地/标签/参考图3张
* [编辑配置]→侧滑面板原地编辑,改完实时刷新预览
*/
import { Product } from '@/types/index';
interface ConfigPreviewPanelProps {
product: Product;
}
export function ConfigPreviewPanel({ product }: ConfigPreviewPanelProps) {
return (
<aside
className="w-64 border-l border-border-default bg-surface-primary p-4 overflow-auto flex-shrink-0"
aria-label="配置预览面板"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-text-primary"></h3>
<a href="/config" aria-label="前往配置中心编辑"
className="text-xs text-brand-orange hover:underline"></a>
</div>
<div className="space-y-3 text-sm">
<InfoRow label="产品" value={product.name} />
<InfoRow label="品类" value={product.category} />
{product.selling_points.length > 0 && (
<div>
<span className="text-xs text-text-secondary block mb-1"></span>
<div className="flex flex-wrap gap-1">
{product.selling_points.map((sp) => (
<span key={sp} className="text-xs bg-brand-cream px-2 py-0.5 rounded-full text-text-secondary">
{sp}
</span>
))}
</div>
</div>
)}
{product.style_tone && <InfoRow label="风格" value={product.style_tone} />}
{product.text_angles.length > 0 && (
<div>
<span className="text-xs text-text-secondary block mb-1"></span>
<div className="flex flex-wrap gap-1">
{product.text_angles.map((a) => (
<span key={a} className="text-xs bg-surface-tertiary px-2 py-0.5 rounded-full text-text-secondary">
{a}
</span>
))}
</div>
</div>
)}
</div>
</aside>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-xs text-text-secondary">{label}</span>
<p className="text-sm text-text-primary">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
// ConfirmActionBar — 确认预览页底部操作:提交审核 / 重新生成
interface ConfirmActionBarProps {
submitting: boolean;
regenerating: boolean;
canSubmit: boolean;
onSubmit: () => void;
onRegenerate: () => void;
}
export function ConfirmActionBar({
submitting, regenerating, canSubmit, onSubmit, onRegenerate,
}: ConfirmActionBarProps) {
return (
<div className="flex gap-3 mt-8">
<button
onClick={onRegenerate}
disabled={regenerating || submitting}
aria-label="重新生成(飞轮-1"
className="px-6 py-2.5 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary disabled:opacity-50"
>
{regenerating ? '重新生成中…' : '重新生成'}
</button>
<button
onClick={onSubmit}
disabled={submitting || regenerating || !canSubmit}
aria-label="提交审核"
className="flex-1 bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
>
{submitting ? '提交中…' : '提交审核'}
</button>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
// ConfirmPreviewImages — 确认预览页已选图片网格
import { ImageCandidate } from '@/types/index';
interface ConfirmPreviewImagesProps {
images: ImageCandidate[];
}
export function ConfirmPreviewImages({ images }: ConfirmPreviewImagesProps) {
return (
<div>
<h3 className="text-sm font-semibold text-text-secondary mb-3"></h3>
{images.length === 0 ? (
<p className="text-text-tertiary text-sm"></p>
) : (
<div className="grid grid-cols-2 gap-2">
{images.map((img) => (
<div key={img.candidate_id} className="rounded-xl overflow-hidden border border-border-default">
<img src={img.url} alt={`选中图片·${img.role}`}
className="w-full aspect-square object-cover" />
<p className="text-xs text-text-secondary text-center py-1">{img.role}</p>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
/**
* CountStepper — 数量调节器(+/- 步进)
*/
interface CountStepperProps {
label: string;
value: number;
onInc: () => void;
onDec: () => void;
}
export function CountStepper({ label, value, onInc, onDec }: CountStepperProps) {
return (
<div>
<p className="text-sm font-medium text-text-primary mb-2">{label}</p>
<div className="flex items-center gap-2">
<button type="button" onClick={onDec} aria-label={`减少${label}`}
className="w-7 h-7 rounded-full border border-border-default text-text-secondary hover:bg-surface-tertiary flex items-center justify-center">
</button>
<span className="w-8 text-center text-sm font-medium">{value}</span>
<button type="button" onClick={onInc} aria-label={`增加${label}`}
className="w-7 h-7 rounded-full border border-border-default text-text-secondary hover:bg-surface-tertiary flex items-center justify-center">
+
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
// ImageActionBar — 屏4挑图底部选中统计 + 提交按钮
interface ImageActionBarProps {
selectedCount: number;
onProceed: () => void;
}
export function ImageActionBar({ selectedCount, onProceed }: ImageActionBarProps) {
return (
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
<span className="text-sm text-text-secondary">
<strong className="text-text-primary">{selectedCount}</strong>
</span>
<button
onClick={onProceed}
disabled={selectedCount === 0}
aria-label="满意,交组长审"
className="bg-brand-orange text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
// ImageProgressCard — 单批图进度卡pending灰/done亮/failed红+重试
// BatchState 定义在此处(契约 image_candidate 无 batch 字段,暂按本地类型保留)
import { clsx } from 'clsx';
import { ImageCandidate } from '@/types/index';
export interface BatchState {
index: number;
status: 'pending' | 'done' | 'failed';
candidates: ImageCandidate[];
failReason?: string;
retryable?: boolean;
}
interface ImageProgressCardProps {
batch: BatchState;
selected: boolean;
onToggle: (batchIndex: number) => void;
onRetry: (batchIndex: number) => void;
}
export function ImageProgressCard({ batch, selected, onToggle, onRetry }: ImageProgressCardProps) {
const isDone = batch.status === 'done';
const isFailed = batch.status === 'failed';
const isPending = batch.status === 'pending';
const firstImg = batch.candidates[0];
return (
<div
onClick={() => isDone && onToggle(batch.index)}
aria-selected={isDone ? selected : undefined}
aria-label={`批次 ${batch.index + 1}${
isPending ? '生成中' : isFailed ? '生成失败' : '已完成'
}${selected ? ',已选中' : ''}`}
className={clsx(
'relative border rounded-xl overflow-hidden transition-all',
isDone && 'cursor-pointer',
isDone && selected && 'border-brand-orange ring-2 ring-brand-orange/30',
isDone && !selected && 'border-border-default hover:border-brand-orange/50',
isFailed && 'border-status-error bg-red-50',
isPending && 'border-border-default bg-surface-tertiary'
)}
>
{/* 图片或占位 */}
<div className="aspect-square">
{isDone && firstImg ? (
<img src={firstImg.url} alt={`批次 ${batch.index + 1} 生成图`}
className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
{isPending && (
<div className="flex flex-col items-center gap-2 text-text-tertiary">
<div className="w-6 h-6 border-2 border-t-transparent border-text-tertiary rounded-full animate-spin" aria-hidden="true" />
<span className="text-xs"></span>
</div>
)}
{isFailed && (
<div className="flex flex-col items-center gap-2 text-status-error px-4 text-center">
<span className="text-xl" aria-hidden="true"></span>
<span className="text-xs"></span>
{batch.failReason && (
<span className="text-xs text-text-secondary line-clamp-2">{batch.failReason}</span>
)}
</div>
)}
</div>
)}
</div>
{/* 底部 badge */}
<div className="absolute bottom-0 left-0 right-0 px-2 py-1 flex items-center justify-between bg-black/30">
<span className="text-white text-xs"> {batch.index + 1}</span>
{isDone && selected && (
<span className="text-white text-xs" aria-hidden="true"></span>
)}
</div>
{/* 失败重试按钮 */}
{isFailed && batch.retryable && (
<button
onClick={(e) => { e.stopPropagation(); onRetry(batch.index); }}
aria-label={`重试批次 ${batch.index + 1}`}
className="absolute top-2 right-2 bg-white text-status-error text-xs rounded-full px-2 py-0.5 border border-status-error hover:bg-red-50"
>
</button>
)}
</div>
);
}
/** 骨架屏占位 */
export function ImageProgressCardSkeleton() {
return (
<div className="border border-border-default rounded-xl overflow-hidden animate-pulse" aria-hidden="true">
<div className="aspect-square bg-surface-tertiary" />
</div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
/**
* ImageProgressHeader — 生图进度标题 + 进度条
* 用于屏4"挑图"顶部,能离开提示
*/
interface ImageProgressHeaderProps {
imageTotal: number;
imageDone: number;
pendingCount: number;
}
export function ImageProgressHeader({ imageTotal, imageDone, pendingCount }: ImageProgressHeaderProps) {
const pct = imageTotal > 0 ? Math.round((imageDone / imageTotal) * 100) : 0;
return (
<>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-text-primary"></h2>
<p className="text-sm text-text-secondary mt-0.5">
{imageTotal} {imageDone}/{imageTotal}
{pendingCount > 0 && ' · 可先去忙别的,好了会更新'}
</p>
</div>
{imageTotal > 0 && (
<span className="text-lg font-bold text-brand-orange">{imageDone}/{imageTotal}</span>
)}
</div>
{imageTotal > 0 && (
<div className="mb-4 h-2 bg-surface-tertiary rounded-full overflow-hidden"
role="progressbar" aria-label={`图片生成进度 ${imageDone}/${imageTotal}`}
aria-valuenow={imageDone} aria-valuemin={0} aria-valuemax={imageTotal}>
<div className="h-full bg-brand-orange rounded-full transition-all"
style={{ width: `${pct}%` }} />
</div>
)}
</>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
/**
* NewTaskForm — 开任务表单屏2主体
* 选产品 + 今天主题 + 数量 + 双轨入口
*/
import { Product, CreateTaskRequest } from '@/types/index';
import { CountStepper } from './CountStepper';
interface NewTaskFormProps {
products: Product[];
selectedProduct: Product | null;
onSelectProduct: (p: Product | null) => void;
form: Omit<CreateTaskRequest, 'product_id'>;
onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void;
submitting: boolean;
error: string;
onSubmitAi: (e: React.FormEvent) => void;
onSubmitImport: (e: React.FormEvent) => void;
}
export function NewTaskForm({
products, selectedProduct, onSelectProduct,
form, onFormChange, submitting, error,
onSubmitAi, onSubmitImport,
}: NewTaskFormProps) {
function adjustCount(field: 'text_count' | 'image_count', delta: number) {
const max = field === 'image_count' ? 8 : 20;
onFormChange({ [field]: Math.max(1, Math.min(max, form[field] + delta)) });
}
// 产品参考图状态image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问
const rawPath = selectedProduct?.image_path || '';
const hasProductImage = !!rawPath;
const imgSrc = rawPath
? '/uploads/' + rawPath.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')
: '';
// 禁降级:产品入镜但无图 → 主轨按钮禁用
const blockedByNoImage = form.need_product_image && !hasProductImage;
return (
<form className="space-y-6 max-w-xl" noValidate>
{/* 选产品 */}
<div>
<label htmlFor="product-select" className="block text-sm font-medium text-text-primary mb-2">
</label>
<select id="product-select"
value={selectedProduct?.id ?? ''}
onChange={(e) => onSelectProduct(products.find((p) => p.id === Number(e.target.value)) ?? null)}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
aria-required="true"
>
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{/* 产品入镜开关 + 参考图状态 */}
<div className="bg-surface-secondary rounded-lg p-4 space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.need_product_image}
onChange={(e) => onFormChange({ need_product_image: e.target.checked })}
className="w-4 h-4 accent-brand-orange"
aria-label="本次产品是否入镜" />
<span className="text-sm font-medium text-text-primary"></span>
</label>
{form.need_product_image && (
hasProductImage ? (
<div className="flex items-center gap-2 text-xs text-status-success">
<img src={imgSrc} alt="产品参考图" className="w-12 h-12 rounded object-cover border border-border-default" />
<span> </span>
</div>
) : (
<div className="text-xs text-status-error bg-red-50 rounded px-3 py-2" role="alert">
-
</div>
)
)}
</div>
{/* 今天主题(关键字段) */}
<div>
<label htmlFor="theme-input" className="block text-sm font-medium text-text-primary mb-2">
</label>
<input id="theme-input" type="text"
value={form.theme}
onChange={(e) => onFormChange({ theme: e.target.value })}
placeholder="如:黄皮提亮·早八伪素颜"
maxLength={100}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
aria-required="true"
/>
<p className="text-xs text-text-tertiary mt-1">{form.theme.length}/100</p>
</div>
{/* 数量设定(不写死) */}
<div className="flex gap-6">
<CountStepper label="生几条文案" value={form.text_count}
onInc={() => adjustCount('text_count', 1)} onDec={() => adjustCount('text_count', -1)} />
<CountStepper label="生几批图" value={form.image_count}
onInc={() => adjustCount('image_count', 1)} onDec={() => adjustCount('image_count', -1)} />
</div>
{error && (
<div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg">
{error}
</div>
)}
{/* 双轨入口 */}
<div className="flex gap-3">
<button type="submit" disabled={submitting || blockedByNoImage} onClick={onSubmitAi}
aria-label="轨A批量生成文案"
title={blockedByNoImage ? '该产品未上传参考图,请先上传或关闭产品入镜' : ''}
className="flex-1 bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
{submitting ? '生成中…' : '开始批量生文 →'}
</button>
<button type="button" disabled={submitting} onClick={onSubmitImport}
aria-label="轨B导入外部文案"
className="px-4 py-2.5 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary disabled:opacity-50">
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
/**
* RecentTasksBar — 底部最近任务横向卡4个
* 任务名 + 状态 + 进度条
*/
import { useEffect } from 'react';
import { useTaskStore } from '@/stores/taskStore';
import { api } from '@/lib/api';
import { TaskListItem, TaskStatus } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import Link from 'next/link';
import { clsx } from 'clsx';
const STATUS_LABELS: Record<TaskStatus, { label: string; color: string }> = {
pending: { label: '待开始', color: 'text-text-tertiary' },
generating: { label: '生成中', color: 'text-status-warning' },
pending_selection: { label: '待挑选', color: 'text-brand-orange' },
pending_review: { label: '待审核', color: 'text-status-info' },
approved: { label: '已通过', color: 'text-status-success' },
rejected: { label: '已打回', color: 'text-status-error' },
archived: { label: '已归档', color: 'text-text-tertiary' },
};
export function RecentTasksBar() {
const { recentTasks, setRecentTasks } = useTaskStore();
useEffect(() => {
loadRecent();
}, []);
async function loadRecent() {
try {
const data = await api.get<PaginatedResponse<TaskListItem>>('/api/v1/tasks?page=1&page_size=4');
setRecentTasks(data.items);
} catch {
// ignore
}
}
if (recentTasks.length === 0) return null;
return (
<div>
<h3 className="text-sm font-medium text-text-secondary mb-3"></h3>
<div className="flex gap-3">
{recentTasks.slice(0, 4).map((task) => {
const statusInfo = STATUS_LABELS[task.status];
return (
<Link key={task.id} href={`/tasks/${task.id}/text`}
aria-label={`任务:${task.theme},状态:${statusInfo.label}`}
className="flex-1 border border-border-default rounded-xl p-3 bg-surface-primary hover:border-brand-orange transition-colors">
<p className="text-sm font-medium text-text-primary line-clamp-1">{task.theme}</p>
<span className={clsx('text-xs', statusInfo.color)}>{statusInfo.label}</span>
{/* 进度条generating 状态时显示) */}
{task.status === 'generating' && (
<div className="mt-2 h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className="h-full bg-brand-orange rounded-full animate-pulse w-1/3" aria-hidden="true" />
</div>
)}
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
// ScoreDimBars — 评分条。优先用 AI 评委明细 dims(7维带理由)无则回退旧5维。
import { TextScore } from '@/types/index';
import { clsx } from 'clsx';
const LEGACY_DIMS = [
{ key: 'title' as const, label: '标题', max: 25 },
{ key: 'emotion' as const, label: '情绪', max: 25 },
{ key: 'selling' as const, label: '买点', max: 25 },
{ key: 'keyword' as const, label: '关键词', max: 20 },
{ key: 'compliance' as const, label: '合规', max: 5 },
];
interface ScoreDimBarsProps {
score: TextScore;
passLine?: number;
}
interface Bar { label: string; val: number; max: number; note?: string; }
export function ScoreDimBars({ score, passLine = 85 }: ScoreDimBarsProps) {
const bars: Bar[] = score.dims && score.dims.length
? score.dims.map((d) => ({ label: d.item, val: d.score, max: d.max || 1, note: d.note }))
: LEGACY_DIMS.map((d) => ({ label: d.label, val: score[d.key], max: d.max }));
return (
<div className="mt-3 space-y-1">
{bars.map((dim) => {
const pct = Math.round((dim.val / dim.max) * 100);
return (
<div key={dim.label} className="flex items-center gap-2" title={dim.note || undefined}>
<span className="text-xs text-text-secondary w-20 text-right truncate">{dim.label}</span>
<div className="flex-1 h-1.5 bg-surface-tertiary rounded-full overflow-hidden"
role="progressbar" aria-label={`${dim.label}得分 ${dim.val}/${dim.max}`}
aria-valuenow={dim.val} aria-valuemin={0} aria-valuemax={dim.max}>
<div className="h-full bg-brand-orange rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-text-secondary w-6 text-right">{dim.val}</span>
</div>
);
})}
<div className="flex items-center justify-between mt-2 pt-2 border-t border-border-default">
<span className="text-xs text-text-secondary"></span>
<span className={clsx(
'text-sm font-bold',
score.total >= passLine ? 'text-status-success' : 'text-text-secondary'
)}>
{score.total}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
/**
* SseStatusBar — SSE 连接状态指示(断线重连 UI
* "连接断开重连中1/5…" / "重连失败任务后台仍在,可刷新"
*/
import { SseConnectionStatus } from '@/lib/sse';
import { clsx } from 'clsx';
interface SseStatusBarProps {
status: SseConnectionStatus;
attempt?: number;
}
export function SseStatusBar({ status, attempt = 0 }: SseStatusBarProps) {
if (status === 'connected' || status === 'closed') return null;
const messages: Record<SseConnectionStatus, string> = {
connecting: '正在连接…',
reconnecting: `连接断开,重连中 ${attempt}/5…`,
failed: '重连失败,任务后台仍在运行,刷新页面可继续',
connected: '',
closed: '',
};
return (
<div
role="status"
aria-live="assertive"
className={clsx(
'px-4 py-2 text-sm text-center',
status === 'failed'
? 'bg-red-50 text-status-error'
: 'bg-yellow-50 text-status-warning'
)}
>
{messages[status]}
{status === 'failed' && (
<button
onClick={() => window.location.reload()}
aria-label="刷新页面"
className="ml-2 underline hover:no-underline"
>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
// TextCandidateCard — 文案候选卡AI评委7维分 + 总评 + 违禁状态 + 导入角标
import { useState } from 'react';
import { TextCandidate } from '@/types/index';
import { ScoreDimBars } from './ScoreDimBars';
import { clsx } from 'clsx' ;
interface TextCandidateCardProps {
candidate: TextCandidate;
selected: boolean;
onToggle: (id: number) => void;
}
const BANNED_STATUS_STYLES = {
pass: null,
auto_fixed: 'bg-green-50 border-brand-green text-brand-green-dark text-xs px-2 py-0.5 rounded-full',
soft_warn: 'bg-yellow-50 border-yellow-400 text-yellow-700 text-xs px-2 py-0.5 rounded-full',
hard_block: 'bg-red-50 border-red-400 text-status-error text-xs px-2 py-0.5 rounded-full',
};
const VERDICT_STYLES: Record<string, string> = {
'优秀': 'text-status-success font-medium',
'合格': 'text-text-secondary',
'不合格':'text-status-error',
};
export function TextCandidateCard({ candidate, selected, onToggle }: TextCandidateCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<article
onClick={() => onToggle(candidate.candidate_id)}
aria-selected={selected}
aria-label={`文案:${candidate.angle_label},总分${candidate.score.total}${selected ? '已选中' : '未选中'}`}
className={clsx(
'relative border rounded-xl p-4 cursor-pointer transition-all',
selected
? 'border-brand-orange bg-brand-cream shadow-sm'
: 'border-border-default bg-surface-primary hover:border-brand-orange/50'
)}
>
{/* 角标:导入来源 */}
{candidate.source === 'import' && (
<span className="absolute top-2 right-2 text-xs bg-surface-tertiary text-text-secondary px-2 py-0.5 rounded-full">
</span>
)}
{/* 选中勾 */}
{selected && (
<span className="absolute top-2 left-2 w-5 h-5 bg-brand-orange text-white rounded-full flex items-center justify-center text-xs"
aria-hidden="true"></span>
)}
<div className="mt-1">
<span className="text-xs font-medium text-brand-orange mb-1 block">{candidate.angle_label}</span>
<p className={clsx(
'text-sm text-text-primary whitespace-pre-wrap',
!expanded && 'line-clamp-4'
)}>{candidate.content}</p>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
className="mt-1 text-xs text-brand-orange hover:underline"
>
{expanded ? '收起' : '展开全文'}
</button>
</div>
{/* 违禁词状态 */}
{candidate.banned_word_status !== 'pass' && (
<div className="mt-2">
<span className={BANNED_STATUS_STYLES[candidate.banned_word_status] ?? ''}>
{candidate.banned_word_status === 'auto_fixed' && '已自动改写'}
{candidate.banned_word_status === 'soft_warn' && '含敏感词,请检查'}
{candidate.banned_word_status === 'hard_block' && '含违禁词,已拦截'}
</span>
</div>
)}
{/* 七维分数条 */}
<ScoreDimBars score={candidate.score} passLine={85} />
{/* AI 评委总评verdict + summary旧数据空字符串时不渲染*/}
{candidate.summary && (
<p className="mt-2 text-xs text-text-secondary leading-relaxed">
{candidate.verdict && (
<span className={clsx('mr-1', VERDICT_STYLES[candidate.verdict] ?? 'text-text-secondary')}>
[{candidate.verdict}]
</span>
)}
{candidate.summary}
</p>
)}
</article>
);
}
/** 骨架屏占位 */
export function TextCandidateCardSkeleton() {
return (
<div className="border border-border-default rounded-xl p-4 animate-pulse space-y-2" aria-hidden="true">
<div className="h-3 bg-surface-tertiary rounded w-1/3" />
<div className="h-4 bg-surface-tertiary rounded w-full" />
<div className="h-4 bg-surface-tertiary rounded w-5/6" />
<div className="h-4 bg-surface-tertiary rounded w-4/6" />
<div className="mt-3 space-y-1">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-2 items-center">
<div className="h-2 bg-surface-tertiary rounded w-12" />
<div className="h-1.5 bg-surface-tertiary rounded flex-1" />
</div>
))}
</div>
</div>
);
}

View 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;
}
}

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;
}

View File

@@ -0,0 +1,76 @@
/**
* authStore — 用户/workspace/role/token
* 扒 banana 范式,全新重建
*/
import { create } from 'zustand';
import { User, UserRole, AuthMeResponse } from '@/types/index';
import { api } from '@/lib/api';
interface AuthState {
user: User | null;
role: UserRole | null;
workspaceId: number | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
setWorkspace: (workspaceId: number, role: UserRole, token: string) => void;
fetchMe: () => Promise<void>;
endLoading: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
role: null,
workspaceId: null,
isAuthenticated: false,
// 初始为 true首屏未知是否已登录先标加载中等 AuthGuard 校验 token 后再落定。
// 否则直接访问受保护页时守卫会在 fetchMe 前误判未登录→踢回 /login竞态
isLoading: true,
login(token, user) {
api.setToken(token);
set({
user,
role: user.role,
workspaceId: user.current_workspace_id,
isAuthenticated: true,
isLoading: false,
});
},
logout() {
api.clearToken();
set({ user: null, role: null, workspaceId: null, isAuthenticated: false, isLoading: false });
},
endLoading() {
set({ isLoading: false });
},
setWorkspace(workspaceId, role, token) {
api.setToken(token);
set((s) => ({
workspaceId,
role,
user: s.user ? { ...s.user, current_workspace_id: workspaceId, role } : null,
}));
},
async fetchMe() {
set({ isLoading: true });
try {
const data = await api.get<AuthMeResponse>('/api/v1/auth/me');
set({
user: data,
role: data.role,
workspaceId: data.current_workspace_id,
isAuthenticated: true,
isLoading: false,
});
} catch {
set({ isLoading: false });
}
},
}));

View File

@@ -0,0 +1,122 @@
/**
* generationStore — SSE进度 / 图片候选 / 飞轮注入
* image_candidate 无 batch 字段,所以 imageCandidates 平铺存储
*/
import { create } from 'zustand';
import { SseConnectionStatus } from '@/lib/sse';
import { TextCandidate, ImageCandidate } from '@/types/index';
interface FailedBatch {
batch: string; // 图片角色名("hook"/"pain"等),对应 BE batch_failed.batch 字段
reason: string;
retryable: boolean;
}
interface GenerationState {
// 连接状态
connectionStatus: SseConnectionStatus;
reconnectAttempt: number;
// 文案生成进度
textDone: number;
textTotal: number;
textCandidates: TextCandidate[];
// 图片生成进度(平铺,不分 batch — 契约无 batch 字段)
imageDone: number;
imageTotal: number;
imageCandidates: ImageCandidate[];
failedBatches: FailedBatch[];
// 飞轮注入(飞轮隐形,只显注入提示,无"训练AI"按钮)
flywheelInjected: {
recentPreference: string;
rejectReasons: string[];
} | null;
setConnectionStatus: (s: SseConnectionStatus, attempt?: number) => void;
setTextProgress: (done: number, total: number) => void;
addTextCandidate: (candidate: TextCandidate) => void;
patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void;
setImageProgress: (done: number, total: number) => void;
addImageCandidate: (candidate: ImageCandidate) => void;
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[] }) => void;
hydrateFromTask: (data: {
textCandidates: TextCandidate[];
imageCandidates: ImageCandidate[];
}) => void;
reset: () => void;
}
export const useGenerationStore = create<GenerationState>((set) => ({
connectionStatus: 'connecting',
reconnectAttempt: 0,
textDone: 0,
textTotal: 0,
textCandidates: [],
imageDone: 0,
imageTotal: 0,
imageCandidates: [],
failedBatches: [],
flywheelInjected: null,
setConnectionStatus(s, attempt = 0) {
set({ connectionStatus: s, reconnectAttempt: attempt });
},
setTextProgress(done, total) {
set({ textDone: done, textTotal: total });
},
addTextCandidate(candidate) {
set((s) => ({ textCandidates: [...s.textCandidates, candidate] }));
},
patchTextCandidateScore(candidateId, score) {
set((s) => ({
textCandidates: s.textCandidates.map((tc) =>
tc.candidate_id === candidateId ? { ...tc, score } : tc
),
}));
},
setImageProgress(done, total) {
set({ imageDone: done, imageTotal: total });
},
addImageCandidate(candidate) {
set((s) => ({ imageCandidates: [...s.imageCandidates, candidate] }));
},
markBatchFailed(batch, reason, retryable) {
set((s) => ({
failedBatches: [...s.failedBatches, { batch, reason, retryable }],
}));
},
setFlywheelInjected(data) {
set({ flywheelInjected: data });
},
// 进入已完成/进行中任务时一次性回填(历史任务 SSE 不重放,必须主动拉接口)
hydrateFromTask({ textCandidates, imageCandidates }) {
set({
textCandidates,
textDone: textCandidates.length,
textTotal: textCandidates.length || 0,
imageCandidates,
imageDone: imageCandidates.length,
imageTotal: imageCandidates.length || 0,
});
},
reset() {
set({
connectionStatus: 'connecting', reconnectAttempt: 0,
textDone: 0, textTotal: 0, textCandidates: [],
imageDone: 0, imageTotal: 0, imageCandidates: [],
failedBatches: [], flywheelInjected: null,
});
},
}));

View File

@@ -0,0 +1,40 @@
/**
* preferenceStore — 本次注入偏好摘要
* 飞轮隐形:只显注入提示,无"训练AI"按钮
* 对应 GET /api/v1/tasks/{id}/preference/context
*/
import { create } from 'zustand';
import { PreferenceContext } from '@/types/index';
import { api } from '@/lib/api';
interface PreferenceState {
context: PreferenceContext | null;
isLoading: boolean;
taskId: number | null;
fetchContext: (taskId: number) => Promise<void>;
clear: () => void;
}
export const usePreferenceStore = create<PreferenceState>((set) => ({
context: null,
isLoading: false,
taskId: null,
async fetchContext(taskId) {
set({ isLoading: true, taskId });
try {
const data = await api.get<PreferenceContext>(
`/api/v1/tasks/${taskId}/preference/context`
);
set({ context: data, isLoading: false });
} catch {
// 无偏好上下文不阻塞流程
set({ isLoading: false });
}
},
clear() {
set({ context: null, taskId: null });
},
}));

View File

@@ -0,0 +1,73 @@
/**
* taskStore — 任务 id / 状态 / 已选文案和图 id
* 按契约 TaskListItem + TaskStatus
*/
import { create } from 'zustand';
import { TaskListItem, TaskStatus } from '@/types/index';
interface TaskState {
currentTaskId: number | null;
currentStatus: TaskStatus | null;
recentTasks: TaskListItem[];
// 已选候选(按契约 candidate_id 传给后端)
selectedTextIds: number[];
selectedImageIds: number[];
setCurrentTask: (id: number, status: TaskStatus) => void;
updateStatus: (status: TaskStatus) => void;
setRecentTasks: (tasks: TaskListItem[]) => void;
toggleTextSelection: (candidateId: number) => void;
toggleImageSelection: (candidateId: number) => void;
clearSelections: () => void;
reset: () => void;
}
export const useTaskStore = create<TaskState>((set) => ({
currentTaskId: null,
currentStatus: null,
recentTasks: [],
selectedTextIds: [],
selectedImageIds: [],
setCurrentTask(id, status) {
set({ currentTaskId: id, currentStatus: status });
},
updateStatus(status) {
set({ currentStatus: status });
},
setRecentTasks(tasks) {
set({ recentTasks: tasks });
},
toggleTextSelection(candidateId) {
set((s) => ({
selectedTextIds: s.selectedTextIds.includes(candidateId)
? s.selectedTextIds.filter((id) => id !== candidateId)
: [...s.selectedTextIds, candidateId],
}));
},
toggleImageSelection(candidateId) {
set((s) => ({
selectedImageIds: s.selectedImageIds.includes(candidateId)
? s.selectedImageIds.filter((id) => id !== candidateId)
: [...s.selectedImageIds, candidateId],
}));
},
clearSelections() {
set({ selectedTextIds: [], selectedImageIds: [] });
},
reset() {
set({
currentTaskId: null,
currentStatus: null,
selectedTextIds: [],
selectedImageIds: [],
});
},
}));

View File

@@ -0,0 +1,110 @@
// DTO 任务/候选/审核/交付类型(按 API契约§3
// 任务
export type TaskStatus =
| 'pending' | 'generating' | 'pending_selection'
| 'pending_review' | 'approved' | 'rejected' | 'archived';
export interface TaskListItem {
id: number;
product_id: number;
theme: string;
status: TaskStatus;
created_at: string;
text_count: number;
image_count: number;
}
export interface CreateTaskRequest {
product_id: number;
benchmark_ids: number[];
theme: string;
text_count: number;
image_count: number;
track: 'ai' | 'import';
need_product_image: boolean; // 本次产品是否入镜true=无图禁生成不降级
}
// 文案候选含AI评委7维分
export type BannedWordStatus = 'pass' | 'auto_fixed' | 'soft_warn' | 'hard_block';
export interface ScoreDim {
item: string;
score: number;
max: number;
note?: string;
}
export interface TextScore {
title: number;
emotion: number;
selling: number;
keyword: number;
compliance: number;
total: number;
dims?: ScoreDim[]; // AI评委明细(7维含理由),存在则优先按它渲染
}
export interface TextCandidate {
candidate_id: number;
angle_label: string;
content: string;
source: 'ai' | 'import';
score: TextScore;
banned_word_status: BannedWordStatus;
verdict?: string; // AI评委总评"优秀"|"合格"|"不合格",旧数据为空字符串
summary?: string; // AI评委一句话总评(含改进点),旧数据为空字符串
}
export interface SelectCandidateRequest { candidate_id: number; }
// 图片候选
export type ImageRole =
| 'hook' | 'pain' | 'proof' | 'quality' | 'credit' | 'convert' | 'main';
export interface ImageCandidate {
candidate_id: number;
strategy: 'A' | 'B' | 'C';
url: string;
role: ImageRole;
}
// 审核
export interface ReviewQueueItem {
task_id: number;
product_name: string;
theme: string;
submitted_at: string;
operator_name: string;
selected_text: { candidate_id: number; angle_label: string; content: string; };
selected_image: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; };
}
export interface RejectRequest { reason: string; }
// 飞轮偏好上下文
export interface PreferenceContext {
recent_preference: string;
reject_reasons: string[];
injected_count: number;
}
// 交付包
export type DeliveryStatus = 'pending' | 'ready' | 'downloaded';
export interface DeliveryPackage {
id: number;
status: DeliveryStatus;
download_url: string | null;
expires_at: string | null;
}
// 统一响应包络
export interface ApiResponse<T> { code: number; data: T; }
export interface ApiError { code: number; message: string; }
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}

95
frontend/src/types/dto.ts Normal file
View File

@@ -0,0 +1,95 @@
// DTO 基础类型 — 认证/用户/ApiKey/产品/标杆/违禁词(按 API契约§3
// 认证 & 用户
export type UserRole = 'admin' | 'supervisor' | 'operator';
export interface User {
id: number;
username: string;
email: string;
current_workspace_id: number;
role: UserRole;
}
export interface Workspace {
id: number;
name: string;
slug: string;
}
export interface AuthMeResponse extends User {
workspace: Workspace;
}
export interface LoginRequest { username: string; password: string; }
export interface LoginResponse { token: string; user: User; }
export interface SwitchWorkspaceRequest { workspace_id: number; }
export interface SwitchWorkspaceResponse { current_workspace_id: number; role: string; token: string; }
// API Key不显余额CLAUDE.md红线
export interface ApiKey {
id: number;
provider: string;
key_last4: string;
created_at: string;
}
export interface CreateApiKeyRequest { provider: string; api_key: string; }
// 产品档案
export interface Product {
id: number;
name: string;
category: string;
source: 'preset' | 'custom';
selling_points: string[];
style_tone: string;
text_angles: string[];
custom_prompt: string | null;
banned_word_ids: number[];
image_path: string | null; // 产品参考图路径(铁律:生图带产品图)
}
export interface CreateProductRequest {
name: string;
category: string;
source: 'preset' | 'custom';
selling_points: string[];
style_tone: string;
text_angles: string[];
custom_prompt: string | null;
banned_word_ids: number[];
}
// 标杆笔记
export interface Benchmark {
id: number;
screenshot_url: string;
highlights: string;
link_url: string | null;
}
export interface CreateBenchmarkRequest {
screenshot_url: string;
highlights: string;
link_url: string | null;
}
// 违禁词
export type BannedWordLevel = 'auto_fix' | 'soft_warn' | 'hard_block';
export interface BannedWord {
id: number;
word: string;
level: BannedWordLevel;
replacement: string | null;
updatable: boolean;
workspace_id: number;
}
export interface CreateBannedWordRequest {
word: string;
level: BannedWordLevel;
replacement: string | null;
updatable: boolean;
}

View File

@@ -0,0 +1,83 @@
/**
* 错误码常量表 — 按契约§0
* 前端统一查此表,不硬编码字符串码
*/
export const ERROR_CODES = {
SUCCESS: 0,
PARAM_INVALID: 40001,
UNAUTHENTICATED: 40101,
FORBIDDEN: 40301,
NOT_FOUND: 40401,
STATE_CONFLICT: 40901,
BUSINESS_VALIDATION: 42201,
SERVER_ERROR: 50001,
AI_CALL_FAILED: 50002,
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
export interface ErrorAction {
message: string;
/** 前端操作提示 */
hint: string;
/** 'redirect' | 'banner' | 'inline' | 'toast' | 'refresh' */
display: string;
redirectTo?: string;
}
/** 所有错误统一查此表,违禁词/限速等细分信息从 response.message 读 */
export const errorCodeMap: Record<number, ErrorAction> = {
[ERROR_CODES.PARAM_INVALID]: {
message: '参数校验失败',
hint: '请检查表单填写',
display: 'inline',
},
[ERROR_CODES.UNAUTHENTICATED]: {
message: '登录已失效',
hint: '正在自动重新登录...',
display: 'toast',
redirectTo: '/login',
},
[ERROR_CODES.FORBIDDEN]: {
message: '无权访问',
hint: '您没有访问此工作区的权限',
display: 'banner',
redirectTo: '/dashboard',
},
[ERROR_CODES.NOT_FOUND]: {
message: '资源不存在',
hint: '请刷新页面后重试',
display: 'toast',
},
[ERROR_CODES.STATE_CONFLICT]: {
message: '当前状态不可操作',
hint: '任务状态已变更,请刷新查看最新状态',
display: 'toast',
},
[ERROR_CODES.BUSINESS_VALIDATION]: {
message: '操作校验失败',
hint: '请检查是否已配置 API Key或文案包含违禁词',
display: 'banner',
},
[ERROR_CODES.SERVER_ERROR]: {
message: '服务器错误',
hint: '生成失败,请重试',
display: 'toast',
},
[ERROR_CODES.AI_CALL_FAILED]: {
message: '生图通道繁忙',
hint: '当前 AI 服务繁忙,请稍后重试',
display: 'banner',
},
};
export function getErrorAction(code: number): ErrorAction {
return (
errorCodeMap[code] ?? {
message: '未知错误',
hint: `错误码:${code},请联系管理员`,
display: 'toast',
}
);
}

View File

@@ -0,0 +1,24 @@
/**
* types/index.ts — 统一类型入口
* 从 dto.ts基础类型和 dto.tasks.ts任务/候选/审核/交付)重导出所有类型
* 所有组件从 @/types/index 或直接从 @/types/dto / @/types/dto.tasks 引入均可
*/
export type {
UserRole, User, Workspace, AuthMeResponse,
LoginRequest, LoginResponse,
SwitchWorkspaceRequest, SwitchWorkspaceResponse,
ApiKey, CreateApiKeyRequest,
Product, CreateProductRequest,
Benchmark, CreateBenchmarkRequest,
BannedWordLevel, BannedWord, CreateBannedWordRequest,
} from './dto';
export type {
TaskStatus, TaskListItem, CreateTaskRequest,
BannedWordStatus, TextScore, TextCandidate, SelectCandidateRequest,
ImageRole, ImageCandidate,
ReviewQueueItem, RejectRequest,
PreferenceContext,
DeliveryStatus, DeliveryPackage,
ApiResponse, ApiError, PaginatedResponse,
} from './dto.tasks';

70
frontend/src/types/sse.ts Normal file
View File

@@ -0,0 +1,70 @@
// SSE 事件类型 — 按契约§211类前端按 event_seq 去重
// 注意BE 用 named SSE eventsFE 必须用 addEventListener 而非 onmessage见 lib/sse.ts
export interface SseBaseEvent { event_seq: number; }
export interface TaskStartedEvent extends SseBaseEvent {
type: 'task_started';
data: { task_id: number; total_text: number; total_image: number };
}
export interface AnalyzeDoneEvent extends SseBaseEvent {
type: 'analyze_done';
data: { features: string[] };
}
export interface TextProgressEvent extends SseBaseEvent {
type: 'text_progress';
data: { done: number; total: number };
}
export interface TextCandidateEvent extends SseBaseEvent {
type: 'text_candidate';
data: {
candidate_id: number;
angle_label: string;
content: string;
score: number; // SSE 推总分整数0-100完整五维分在 GET /tasks/{id} 返回
};
}
export interface ImageProgressEvent extends SseBaseEvent {
type: 'image_progress';
data: { done: number; total: number };
}
export interface ImageCandidateEvent extends SseBaseEvent {
type: 'image_candidate';
data: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; role: string };
}
export interface FlywheelInjectedEvent extends SseBaseEvent {
type: 'flywheel_injected';
data: { recent_preference: string; reject_reasons: string[] };
}
export interface BatchFailedEvent extends SseBaseEvent {
type: 'batch_failed';
data: { batch: string; reason: string; retryable: boolean }; // batch = 图片角色名字符串
}
export interface TaskDoneEvent extends SseBaseEvent {
type: 'task_done';
data: { task_id: number; status: string };
}
export interface SseErrorEvent extends SseBaseEvent {
type: 'error';
data: { code: number; message: string };
}
export interface HeartbeatEvent extends SseBaseEvent {
type: 'heartbeat';
data: { ts: number };
}
export type SseEvent =
| TaskStartedEvent | AnalyzeDoneEvent | TextProgressEvent | TextCandidateEvent
| ImageProgressEvent | ImageCandidateEvent | FlywheelInjectedEvent | BatchFailedEvent
| TaskDoneEvent | SseErrorEvent | HeartbeatEvent;