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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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