baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user