baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
24
frontend/README.md
Normal file
24
frontend/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Clover Frontend
|
||||
|
||||
Next.js(React)— 5屏骨架
|
||||
|
||||
## 目录规划
|
||||
```
|
||||
frontend/src/
|
||||
app/ # Next.js App Router
|
||||
components/ # 可复用组件
|
||||
lib/ # API client(按 openapi.yaml 生成)
|
||||
types/ # TypeScript 类型
|
||||
```
|
||||
|
||||
## 5屏
|
||||
1. 管理配置台 — 产品档案/标杆笔记/违禁词/API Key录入
|
||||
2. 开任务 — 选产品+标杆+今天主题+设定数量,发起生成
|
||||
3. 挑文案+挑图 — SSE实时看生成进度,8选3文案/挑图,显示"本次已注入"
|
||||
4. 确认预览 — 提交审核
|
||||
5. 审核台(组长) — 待审队列/通过/打回写原因
|
||||
|
||||
## 前端红线(不得违反)
|
||||
- 不展示 Token 余额/用量
|
||||
- 不做积分中心/billing 页
|
||||
- 飞轮偏好信号不暴露独立 POST 给前端(防伪造)
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
24
frontend/next.config.js
Normal file
24
frontend/next.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ['localhost'],
|
||||
},
|
||||
async rewrites() {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiBase}/api/:path*`,
|
||||
},
|
||||
{
|
||||
// 图片等静态资源:后端 /uploads 由 StaticFiles 提供,前端代理过去
|
||||
// 否则 <img src="/uploads/..."> 会请求前端3000端口导致404破图
|
||||
source: '/uploads/:path*',
|
||||
destination: `${apiBase}/uploads/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
6222
frontend/package-lock.json
generated
Normal file
6222
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "clover-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"next": "14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
58
frontend/src/app/config/page.tsx
Normal file
58
frontend/src/app/config/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/app/dashboard/page.tsx
Normal file
51
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
frontend/src/app/globals.css
Normal file
8
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 最小宽度 1280px,不做移动端 */
|
||||
body {
|
||||
min-width: 1280px;
|
||||
}
|
||||
174
frontend/src/app/history/components.tsx
Normal file
174
frontend/src/app/history/components.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/app/history/page.tsx
Normal file
95
frontend/src/app/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/app/layout.tsx
Normal file
49
frontend/src/app/layout.tsx
Normal 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 不可用 Store,Client 组件内联) */
|
||||
function UserBadge() {
|
||||
// 通过 Client 组件获取 store 数据,此处为 Server Component 占位
|
||||
return <UserBadgeClient />;
|
||||
}
|
||||
|
||||
// 内联 Client 组件,避免额外文件
|
||||
import UserBadgeClient from '@/components/layout/UserBadge';
|
||||
86
frontend/src/app/login/page.tsx
Normal file
86
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
frontend/src/app/page.tsx
Normal file
9
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 根路由 / → 重定向到 /dashboard
|
||||
* AuthGuard 会根据登录态再跳到对应首页
|
||||
*/
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
98
frontend/src/app/review/page.tsx
Normal file
98
frontend/src/app/review/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/app/tasks/[id]/confirm/page.tsx
Normal file
91
frontend/src/app/tasks/[id]/confirm/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/app/tasks/[id]/images/page.tsx
Normal file
86
frontend/src/app/tasks/[id]/images/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/app/tasks/[id]/text/page.tsx
Normal file
94
frontend/src/app/tasks/[id]/text/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/app/tasks/new/page.tsx
Normal file
99
frontend/src/app/tasks/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/AuthGuard.tsx
Normal file
76
frontend/src/components/AuthGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
65
frontend/src/components/FlywheelBanner.tsx
Normal file
65
frontend/src/components/FlywheelBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/config/ApiKeysTab.tsx
Normal file
98
frontend/src/components/config/ApiKeysTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/config/BannedWordsTab.tsx
Normal file
90
frontend/src/components/config/BannedWordsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/config/BannedWordsTable.tsx
Normal file
54
frontend/src/components/config/BannedWordsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/config/BenchmarkForm.tsx
Normal file
66
frontend/src/components/config/BenchmarkForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/config/BenchmarksTab.tsx
Normal file
98
frontend/src/components/config/BenchmarksTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/config/ProductSubComponents.tsx
Normal file
181
frontend/src/components/config/ProductSubComponents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/config/ProductsTab.tsx
Normal file
80
frontend/src/components/config/ProductsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/layout/ProgressBar.tsx
Normal file
73
frontend/src/components/layout/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/layout/Sidebar.tsx
Normal file
72
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/layout/UserBadge.tsx
Normal file
40
frontend/src/components/layout/UserBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/review/RejectModal.tsx
Normal file
80
frontend/src/components/review/RejectModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/review/ReviewCard.tsx
Normal file
79
frontend/src/components/review/ReviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/tasks/ConfigPreviewPanel.tsx
Normal file
65
frontend/src/components/tasks/ConfigPreviewPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/tasks/ConfirmActionBar.tsx
Normal file
34
frontend/src/components/tasks/ConfirmActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/tasks/ConfirmPreviewImages.tsx
Normal file
28
frontend/src/components/tasks/ConfirmPreviewImages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/tasks/CountStepper.tsx
Normal file
29
frontend/src/components/tasks/CountStepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/tasks/ImageActionBar.tsx
Normal file
24
frontend/src/components/tasks/ImageActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/tasks/ImageProgressCard.tsx
Normal file
99
frontend/src/components/tasks/ImageProgressCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/tasks/ImageProgressHeader.tsx
Normal file
38
frontend/src/components/tasks/ImageProgressHeader.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
frontend/src/components/tasks/NewTaskForm.tsx
Normal file
126
frontend/src/components/tasks/NewTaskForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/tasks/RecentTasksBar.tsx
Normal file
66
frontend/src/components/tasks/RecentTasksBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/tasks/ScoreDimBars.tsx
Normal file
53
frontend/src/components/tasks/ScoreDimBars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/tasks/SseStatusBar.tsx
Normal file
48
frontend/src/components/tasks/SseStatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/tasks/TextCandidateCard.tsx
Normal file
116
frontend/src/components/tasks/TextCandidateCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/src/hooks/useSse.ts
Normal file
129
frontend/src/hooks/useSse.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
/**
|
||||
* useSse — SSE 客户端 React hook
|
||||
* 接收 SSE 事件,分派到 generationStore
|
||||
* event_seq 去重 + 断线重连UI
|
||||
* 五维分:SSE 推 total,五维详情通过 GET /tasks/{id} 补全
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createSseClient, SseClient, SseConnectionStatus } from '@/lib/sse';
|
||||
import { SseEvent } from '@/types/sse';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useSse(taskId: number | null) {
|
||||
const clientRef = useRef<SseClient | null>(null);
|
||||
const store = useGenerationStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
store.reset();
|
||||
|
||||
// reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。
|
||||
// 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成
|
||||
// reset 把 hydrate 数据清空的竞态(SPA 跳转进来会看到空白)。
|
||||
let cancelled = false;
|
||||
api.get<{
|
||||
text_candidates?: TextCandidate[];
|
||||
image_candidates?: ImageCandidate[];
|
||||
}>(`/api/v1/tasks/${taskId}`).then((task) => {
|
||||
if (cancelled) return;
|
||||
const texts = task.text_candidates ?? [];
|
||||
const images = task.image_candidates ?? [];
|
||||
if (texts.length || images.length) {
|
||||
store.hydrateFromTask({ textCandidates: texts, imageCandidates: images });
|
||||
}
|
||||
}).catch(() => { /* 进行中任务靠 SSE 填充 */ });
|
||||
|
||||
const client = createSseClient(taskId, {
|
||||
onEvent: (event: SseEvent) => handleEvent(event, store, taskId),
|
||||
onStatusChange: (status: SseConnectionStatus, attempt?: number) => {
|
||||
store.setConnectionStatus(status, attempt);
|
||||
},
|
||||
});
|
||||
clientRef.current = client;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
client.close();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
return {
|
||||
connectionStatus: store.connectionStatus,
|
||||
reconnectAttempt: store.reconnectAttempt,
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(
|
||||
event: SseEvent,
|
||||
store: ReturnType<typeof useGenerationStore.getState>,
|
||||
taskId: number,
|
||||
) {
|
||||
switch (event.type) {
|
||||
case 'task_started':
|
||||
store.setTextProgress(0, event.data.total_text);
|
||||
store.setImageProgress(0, event.data.total_image);
|
||||
break;
|
||||
|
||||
case 'text_progress':
|
||||
store.setTextProgress(event.data.done, event.data.total);
|
||||
break;
|
||||
|
||||
case 'text_candidate': {
|
||||
// SSE 只推 total 分;先用 total 占位,再异步补全五维分
|
||||
const totalScore = typeof event.data.score === 'number' ? event.data.score : 0;
|
||||
const tc: TextCandidate = {
|
||||
candidate_id: event.data.candidate_id,
|
||||
angle_label: event.data.angle_label,
|
||||
content: event.data.content,
|
||||
source: 'ai',
|
||||
score: { title: 0, emotion: 0, selling: 0, keyword: 0, compliance: 0, total: totalScore },
|
||||
banned_word_status: 'pass',
|
||||
};
|
||||
store.addTextCandidate(tc);
|
||||
// 异步补全五维分(不阻塞 SSE)
|
||||
api.get<{ text_candidates?: Array<{ candidate_id: number; score?: TextCandidate['score'] }> }>(
|
||||
`/api/v1/tasks/${taskId}`
|
||||
).then((task) => {
|
||||
const found = task.text_candidates?.find((c) => c.candidate_id === tc.candidate_id);
|
||||
if (found?.score) store.patchTextCandidateScore(tc.candidate_id, found.score);
|
||||
}).catch(() => { /* 五维分补全失败不影响主流程 */ });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'image_progress':
|
||||
store.setImageProgress(event.data.done, event.data.total);
|
||||
break;
|
||||
|
||||
case 'image_candidate': {
|
||||
const ic: ImageCandidate = {
|
||||
candidate_id: event.data.candidate_id,
|
||||
strategy: event.data.strategy,
|
||||
url: event.data.url,
|
||||
role: event.data.role as ImageCandidate['role'],
|
||||
};
|
||||
store.addImageCandidate(ic);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'batch_failed':
|
||||
store.markBatchFailed(event.data.batch, event.data.reason, event.data.retryable);
|
||||
break;
|
||||
|
||||
case 'flywheel_injected':
|
||||
store.setFlywheelInjected({
|
||||
recentPreference: event.data.recent_preference,
|
||||
rejectReasons: event.data.reject_reasons,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
case 'heartbeat':
|
||||
case 'analyze_done':
|
||||
case 'task_done':
|
||||
break;
|
||||
}
|
||||
}
|
||||
116
frontend/src/lib/api.ts
Normal file
116
frontend/src/lib/api.ts
Normal 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
109
frontend/src/lib/sse.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// SSE 客户端 — 按契约§2 + 安全红线(不传 JWT 进 URL,先换 ticket 再建 EventSource)
|
||||
// 特性:ticket 认证 + event_seq 去重 + 最多5次指数退避重连
|
||||
// 注意:BE 用 named events(event: 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 types(onmessage 收不到 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;
|
||||
}
|
||||
76
frontend/src/stores/authStore.ts
Normal file
76
frontend/src/stores/authStore.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
122
frontend/src/stores/generationStore.ts
Normal file
122
frontend/src/stores/generationStore.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
40
frontend/src/stores/preferenceStore.ts
Normal file
40
frontend/src/stores/preferenceStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
73
frontend/src/stores/taskStore.ts
Normal file
73
frontend/src/stores/taskStore.ts
Normal 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: [],
|
||||
});
|
||||
},
|
||||
}));
|
||||
110
frontend/src/types/dto.tasks.ts
Normal file
110
frontend/src/types/dto.tasks.ts
Normal 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
95
frontend/src/types/dto.ts
Normal 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;
|
||||
}
|
||||
83
frontend/src/types/errors.ts
Normal file
83
frontend/src/types/errors.ts
Normal 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',
|
||||
}
|
||||
);
|
||||
}
|
||||
24
frontend/src/types/index.ts
Normal file
24
frontend/src/types/index.ts
Normal 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
70
frontend/src/types/sse.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// SSE 事件类型 — 按契约§2,11类,前端按 event_seq 去重
|
||||
// 注意:BE 用 named SSE events,FE 必须用 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;
|
||||
|
||||
53
frontend/tailwind.config.js
Normal file
53
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// design tokens — 暖橙/奶油色+绿色小草
|
||||
brand: {
|
||||
orange: '#F97316',
|
||||
'orange-light': '#FED7AA',
|
||||
cream: '#FEFCE8',
|
||||
'cream-dark': '#FEF9C3',
|
||||
green: '#22C55E',
|
||||
'green-light': '#DCFCE7',
|
||||
'green-dark': '#16A34A',
|
||||
},
|
||||
surface: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#F9FAFB',
|
||||
tertiary: '#F3F4F6',
|
||||
},
|
||||
text: {
|
||||
primary: '#111827',
|
||||
secondary: '#6B7280',
|
||||
tertiary: '#9CA3AF',
|
||||
},
|
||||
border: {
|
||||
default: '#E5E7EB',
|
||||
focus: '#F97316',
|
||||
},
|
||||
status: {
|
||||
error: '#EF4444',
|
||||
warning: '#F59E0B',
|
||||
success: '#22C55E',
|
||||
info: '#3B82F6',
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
// token 化间距
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem',
|
||||
},
|
||||
minWidth: {
|
||||
'320': '1280px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user