上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事

- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -37,7 +37,7 @@ interface AuthGuardProps {
export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, role, isLoading, fetchMe, endLoading } = useAuthStore();
const { isAuthenticated, role, user, isLoading, fetchMe, endLoading } = useAuthStore();
useEffect(() => {
// 首屏校验:有 token 且 store 未初始化 → 拉 me无 token → 结束 loading 让守卫放行判断
@@ -60,11 +60,20 @@ export function AuthGuard({ children }: AuthGuardProps) {
return;
}
if (user?.must_change_password && pathname !== '/change-password') {
router.replace('/change-password');
return;
}
if (!user?.must_change_password && pathname === '/change-password') {
router.replace('/dashboard');
return;
}
const required = getRequiredRoles(pathname);
if (required && role && !required.includes(role)) {
router.replace('/dashboard');
}
}, [isAuthenticated, role, isLoading, pathname, router]);
}, [isAuthenticated, role, user?.must_change_password, isLoading, pathname, router]);
if (isLoading) {
return (

View File

@@ -11,26 +11,37 @@ interface FlywheelBannerProps {
}
export function FlywheelBanner({ context }: FlywheelBannerProps) {
if (!context.injected_count && !context.recent_preference) return null;
const signalCount = context.signal_count ?? 0;
if (!context.injected_count && !context.recent_preference && !signalCount) 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"
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex flex-col gap-1"
>
<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>
{signalCount > 0 && (
<div className="flex items-start gap-2">
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<span className="font-medium"> {signalCount} </span>
</div>
)}
{(context.recent_preference || (context.reject_reasons?.length ?? 0) > 0) && (
<div className="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>
)}
</div>
);
}
@@ -41,24 +52,34 @@ export function FlywheelBanner({ context }: FlywheelBannerProps) {
interface FlywheelBannerFromSseProps {
recentPreference: string;
rejectReasons: string[];
signalCount?: number;
}
export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: FlywheelBannerFromSseProps) {
export function FlywheelBannerFromSse({ recentPreference, rejectReasons, signalCount }: FlywheelBannerFromSseProps) {
const count = signalCount ?? 0;
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"
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex flex-col gap-1"
>
<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>
)}
{count > 0 && (
<div className="flex items-start gap-2">
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<span className="font-medium"> {count} </span>
</div>
)}
<div className="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>
</div>
);

View File

@@ -0,0 +1,158 @@
'use client';
/**
* StageLoadingPanel — 通用多阶段加载面板A1/A2/A3 复用)
* - 阶段列表:待完成灰 / 当前橙色 pulse 点 / 已完成绿勾
* - 线性进度条(公式可配)+ ETA 胶囊
* - 图片骨架屏 shimmer可选
*/
import { useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx';
export interface StageItem {
label: string;
}
interface Props {
stages: StageItem[];
/** 当前激活阶段索引0-based-1 = 未开始;>= stages.length = 全完成 */
activeIdx: number;
/** ETA 目标秒数(用于线性估算) */
targetSec: number;
/** 任务启动时间戳ms用于推算进度和 ETA */
startedAt: number | null;
/** 进度条已完成比例0-100若提供则直接用不再用时间公式 */
realPct?: number;
/** 标题(左上) */
title: string;
/** 图片骨架数量0 = 不显示骨架屏) */
skeletonCount?: number;
className?: string;
}
// 线性估算进度公式min(96, base + elapsed * rate)
function calcPct(elapsed: number, targetSec: number): number {
const rate = 96 / (targetSec * 0.85); // 85% 时间跑到 96%
return Math.min(96, Math.round(elapsed * rate));
}
// 进度条与步骤耦合:时间驱动平滑动画,但被「当前步骤」的区间钳住——
// 步骤不往前走,进度条就不会冲过这一步的上限边界。
// 杜绝倩倩姐反馈的"进度条快满了、下面步骤还卡在第一步、看起来像卡死"。
// floor=当前步起点(真实进度,步骤前进时立刻顶上来)ceil-gap=当前步上限(留余量表示"未完成")。
function stepBoundedPct(
elapsed: number, targetSec: number, activeIdx: number, total: number,
): number {
const timed = calcPct(elapsed, targetSec);
if (total <= 0) return timed;
const idx = Math.max(0, activeIdx); // -1(未开始)按第0步对待条停在最左
const floor = (idx / total) * 100;
const ceil = ((idx + 1) / total) * 100;
const gap = Math.min(3, ceil - floor) ; // 距上限留点空隙,肉眼可见"这步还在跑"
return Math.round(Math.min(ceil - gap, Math.max(floor, timed)));
}
function formatEta(remainSec: number): string {
if (remainSec <= 0) return '即将完成';
if (remainSec < 60) return `${Math.ceil(remainSec)}`;
return `${Math.ceil(remainSec / 60)} 分钟`;
}
export function StageLoadingPanel({
stages, activeIdx, targetSec, startedAt, realPct,
title, skeletonCount = 0, className,
}: Props) {
const [elapsed, setElapsed] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!startedAt) return;
const tick = () => setElapsed(Math.floor((Date.now() - startedAt) / 1000));
tick();
timerRef.current = setInterval(tick, 1000);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [startedAt]);
const pct = realPct !== undefined
? realPct
: (startedAt ? stepBoundedPct(elapsed, targetSec, activeIdx, stages.length) : 0);
const remainSec = Math.max(0, targetSec - elapsed);
// 超时提示
let hint = '';
if (elapsed > 120) hint = '耗时较长,可降低生成数量后重试';
else if (elapsed > 60) hint = 'AI 仍在生成,请保持页面打开';
const allDone = activeIdx >= stages.length;
return (
<div className={clsx('rounded-xl border border-brand-orange/20 bg-brand-cream p-4', className)}>
{/* 标题行 + ETA 胶囊 */}
<div className="flex items-center justify-between mb-3">
<span className="font-semibold text-text-primary text-sm">{title}</span>
{!allDone && startedAt && (
<span className="text-xs bg-orange-100 text-brand-orange px-2.5 py-0.5 rounded-full font-medium">
{hint || formatEta(remainSec)}
</span>
)}
{allDone && (
<span className="text-xs bg-green-100 text-green-600 px-2.5 py-0.5 rounded-full font-medium">
</span>
)}
</div>
{/* 进度条 */}
<div
className="mb-3 h-1.5 bg-surface-tertiary rounded-full overflow-hidden"
role="progressbar" aria-valuenow={allDone ? 100 : pct} aria-valuemin={0} aria-valuemax={100}
>
<div
className={clsx(
'h-full rounded-full transition-all duration-1000',
allDone ? 'bg-green-500' : 'bg-brand-orange',
)}
style={{ width: `${allDone ? 100 : pct}%` }}
/>
</div>
{/* 阶段列表 */}
<ol className="space-y-1.5">
{stages.map((s, i) => {
const isDone = i < activeIdx || allDone;
const isActive = i === activeIdx && !allDone;
return (
<li key={i} className="flex items-center gap-2 text-sm">
{/* 状态指示器 */}
{isDone ? (
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-green-500 flex items-center justify-center text-white text-[10px]"></span>
) : isActive ? (
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-brand-orange animate-pulse" aria-hidden="true" />
) : (
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-gray-200" aria-hidden="true" />
)}
<span className={clsx(
isDone ? 'text-text-secondary' :
isActive ? 'text-brand-orange font-medium' : 'text-text-tertiary',
)}>
{s.label}
</span>
</li>
);
})}
</ol>
{/* 骨架屏占位(图片未到时展示 shimmer */}
{skeletonCount > 0 && !allDone && (
<div className="mt-4 grid grid-cols-3 gap-2">
{Array.from({ length: skeletonCount }).map((_, i) => (
<div
key={i}
className="aspect-[3/4] rounded-lg bg-gradient-to-r from-gray-100 via-gray-200 to-gray-100 bg-[length:200%_100%] animate-[shimmer_1.5s_infinite]"
aria-hidden="true"
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
// ApiKeysTab — API Key 录入只录不显余额CLAUDE.md红线
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { ApiKey, CreateApiKeyRequest, PaginatedResponse, ApiError } from '@/types/index';
import { getErrorAction } from '@/types/errors';
@@ -10,7 +10,13 @@ export function ApiKeysTab() {
const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'openai', api_key: '' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const keyInputRef = useRef<HTMLInputElement>(null);
// 当前选中 provider 是否已录过 → 决定是「保存」还是「更新覆盖」
const isOverwrite = keys.some((k) => k.provider === form.provider);
useEffect(() => { loadKeys(); }, []);
@@ -22,10 +28,18 @@ export function ApiKeysTab() {
} catch { /* silent */ } finally { setLoading(false); }
}
// 点列表「修改」:把该 provider 填回表单、清空 key 待重录、聚焦输入框(覆盖式改 key
function handleEdit(provider: string) {
setForm({ provider, api_key: '' });
setError('');
setSuccess('');
keyInputRef.current?.focus();
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
setSaving(true); setError('');
setSaving(true); setError(''); setSuccess('');
try {
await api.post('/api/v1/api-keys', form);
setForm({ provider: 'openai', api_key: '' });
@@ -36,6 +50,18 @@ export function ApiKeysTab() {
} finally { setSaving(false); }
}
async function handleTest() {
if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
setTesting(true); setError(''); setSuccess('');
try {
await api.post('/api/v1/api-keys/test', form);
setSuccess('测试通过,可以保存');
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || '测试失败,请检查 Key');
} finally { setTesting(false); }
}
async function handleDelete(id: number) {
if (!confirm('确认删除此 Key')) return;
try {
@@ -47,33 +73,41 @@ export function ApiKeysTab() {
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
API Key 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 }))}
onChange={(e) => { setError(''); setSuccess(''); 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>
<option value="openai"> apiports+</option>
<option value="codeproxy"> codeproxyapiports </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 }))}
<label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">
API Key{isOverwrite && <span className="text-brand-orange ml-1"></span>}
</label>
<input id="api-key-input" ref={keyInputRef} type="password" value={form.api_key}
onChange={(e) => { setError(''); setSuccess(''); 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 type="button" onClick={handleTest} disabled={testing || saving}
className="border border-border-default text-text-secondary rounded-lg px-4 py-2 text-sm font-medium hover:border-brand-orange hover:text-brand-orange disabled:opacity-50 whitespace-nowrap">
{testing ? '测试中…' : '测试'}
</button>
<button type="submit" disabled={saving || testing} aria-label={isOverwrite ? '更新 API Key' : '保存 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 whitespace-nowrap">
{saving ? '保存中…' : isOverwrite ? '更新覆盖' : '保存'}
</button>
</form>
{error && <p role="alert" className="text-status-error text-sm">{error}</p>}
{success && <p role="status" className="text-green-700 text-sm">{success}</p>}
{loading ? (
<div className="text-text-secondary text-sm"></div>
) : (
@@ -84,8 +118,12 @@ export function ApiKeysTab() {
<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>
<span className="flex items-center gap-3">
<button onClick={() => handleEdit(k.provider)} aria-label={`修改 ${k.provider} Key`}
className="text-brand-orange text-xs hover:underline"></button>
<button onClick={() => handleDelete(k.id)} aria-label={`删除 ${k.provider} Key`}
className="text-status-error text-xs hover:underline"></button>
</span>
</li>
))}
{keys.length === 0 && (

View File

@@ -4,7 +4,6 @@
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { CreateBenchmarkRequest } from '@/types/index';
interface BenchmarkFormProps {
productId: number;
@@ -13,18 +12,33 @@ interface BenchmarkFormProps {
}
export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormProps) {
const [form, setForm] = useState<CreateBenchmarkRequest>({
screenshot_url: '', highlights: '', link_url: null,
});
const [linkUrl, setLinkUrl] = useState('');
const [highlights, setHighlights] = useState('');
const [screenshot, setScreenshot] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.screenshot_url || !form.highlights) return;
if (!screenshot && !linkUrl.trim() && !highlights.trim()) {
setError('请至少上传截图、填写原始链接或填写亮点');
return;
}
if (screenshot && screenshot.size > 10 * 1024 * 1024) {
setError('截图超过 10 MB');
return;
}
setSaving(true);
setError('');
try {
await api.post(`/api/v1/products/${productId}/benchmarks`, form);
const formData = new FormData();
if (screenshot) formData.append('screenshot', screenshot);
formData.append('highlights', highlights.trim());
formData.append('link_url', linkUrl.trim());
await api.postForm(`/api/v1/products/${productId}/benchmarks/upload`, formData);
onSaved();
} catch (err) {
setError((err as Error)?.message || '保存失败,请重试');
} finally {
setSaving(false);
}
@@ -35,22 +49,32 @@ export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormPro
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://..."
<label htmlFor="bm-link" className="block text-sm font-medium mb-1"></label>
<input id="bm-link" value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://www.xiaohongshu.com/..."
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"
/>
<p className="mt-1 text-xs text-text-tertiary"></p>
</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 }))}
<label htmlFor="bm-screenshot" className="block text-sm font-medium mb-1"></label>
<input id="bm-screenshot" type="file" accept="image/jpeg,image/png,image/webp"
onChange={(e) => setScreenshot(e.target.files?.[0] ?? null)}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
/>
{screenshot && (
<p className="mt-1 text-xs text-text-secondary truncate">{screenshot.name}</p>
)}
</div>
<div>
<label htmlFor="bm-highlights" className="block text-sm font-medium mb-1"></label>
<textarea id="bm-highlights" value={highlights} rows={3}
onChange={(e) => setHighlights(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>
{error && <p role="alert" className="text-sm text-status-error">{error}</p>}
<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">

View File

@@ -8,6 +8,12 @@ import { Benchmark, Product } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { BenchmarkForm } from './BenchmarkForm';
function toUploadSrc(path: string | null) {
if (!path) return '';
if (path.startsWith('http://') || path.startsWith('https://')) return path;
return `/uploads/${path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`;
}
export function BenchmarksTab() {
const [products, setProducts] = useState<Product[]>([]);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
@@ -67,10 +73,38 @@ export function BenchmarksTab() {
<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" />
{b.screenshot_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={toUploadSrc(b.screenshot_url)} alt="标杆笔记截图"
className="w-full h-32 object-cover bg-surface-tertiary" />
) : (
<div className="flex h-32 items-center justify-center bg-surface-tertiary text-xs text-text-tertiary">
</div>
)}
<div className="p-3">
<p className="text-xs text-text-secondary line-clamp-2">{b.highlights}</p>
<div className="mb-2 flex items-center gap-2 text-[11px]">
<span className={
b.analyze_status === 'done'
? 'rounded bg-green-50 px-2 py-0.5 text-status-success'
: b.analyze_status === 'failed'
? 'rounded bg-red-50 px-2 py-0.5 text-status-error'
: 'rounded bg-yellow-50 px-2 py-0.5 text-status-warning'
}>
{b.analyze_status === 'done' ? '分析完成' : b.analyze_status === 'failed' ? '分析失败' : '待分析'}
</span>
{b.analysis_source === 'fallback' && (
<span className="rounded bg-yellow-50 px-2 py-0.5 text-status-warning"></span>
)}
</div>
{b.analysis_warning && (
<p className="mb-2 rounded bg-yellow-50 px-2 py-1 text-[11px] text-status-warning">
{b.analysis_warning}
</p>
)}
{b.highlights && (
<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>
@@ -80,7 +114,7 @@ export function BenchmarksTab() {
))}
{benchmarks.length === 0 && (
<p className="col-span-3 text-text-tertiary text-sm py-4 text-center">
"传成品包"
</p>
)}
</div>

View File

@@ -0,0 +1,88 @@
'use client';
/**
* CreateUserModal — 管理员新建账号弹窗
* 用户名/邮箱/角色/初始密码;建成后该账号首登强制改密。
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { ApiError } from '@/types';
const INPUT_CLS = 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange transition-colors';
const ROLES = [
{ value: 'operator', label: '运营(日常出稿)' },
{ value: 'supervisor', label: '组长(可审核)' },
{ value: 'admin', label: '管理员(含账号管理)' },
];
export function CreateUserModal({ onClose, onCreated }: {
onClose: () => void;
onCreated: () => void;
}) {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState('operator');
const [initPassword, setInitPassword] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (initPassword.length < 8) {
setError('初始密码至少 8 位');
return;
}
setSaving(true);
setError('');
try {
await api.post('/api/v1/users', {
username, email, role, init_password: initPassword,
});
onCreated();
} catch (err) {
setError((err as ApiError)?.message || '创建失败');
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-text-primary mb-4"></h3>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<input value={username} onChange={(e) => setUsername(e.target.value)} className={INPUT_CLS} required />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className={INPUT_CLS} required />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<select value={role} onChange={(e) => setRole(e.target.value)} className={INPUT_CLS}>
{ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">8</label>
<input type="text" value={initPassword} onChange={(e) => setInitPassword(e.target.value)}
className={INPUT_CLS} placeholder="员工首登须改密" required />
</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-2 mt-2">
<button type="button" onClick={onClose}
className="flex-1 border border-border-default text-text-secondary rounded-lg py-2 text-sm hover:bg-surface-tertiary">
</button>
<button type="submit" disabled={saving}
className="flex-1 bg-brand-orange text-white rounded-lg py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
{saving ? '创建中…' : '创建'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
'use client';
/**
* ProductFormFull — 统一产品档案表单(新建 + 编辑共用倩倩姐2026-06-26拍板
* 字段:产品名(必填)/品类/品牌词/风格调性/目标人群/卖点(多条,一行一条)。
* 这些字段全部喂进文案+生图 prompt(_text_prompt.py / storyboard.py),填得越细生成越贴合。
* product 有值=编辑(PUT /products/{id});无值=新建(POST /products)。
* 图片管理不在此组件(由 ProductCard / ProductEditPanel 各自的 ProductImageManager 负责)。
* text_angles / custom_prompt 偏技术暂不暴露,编辑时原样保留不清空。
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { Product, CreateProductRequest } from '@/types/index';
export function ProductFormFull({
product,
onSaved,
onCancel,
}: {
product?: Product;
onSaved: (p: Product) => void;
onCancel: () => void;
}) {
const isEdit = !!product;
const [name, setName] = useState(product?.name ?? '');
const [category, setCategory] = useState(product?.category ?? '');
const [brandKeyword, setBrandKeyword] = useState(product?.brand_keyword ?? '');
const [styleTone, setStyleTone] = useState(product?.style_tone ?? '');
const [targetAudience, setTargetAudience] = useState(product?.target_audience ?? '');
const [sellingPointsText, setSellingPointsText] = useState(
(product?.selling_points ?? []).join('\n'),
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) { setError('产品名不能为空'); return; }
setSaving(true); setError('');
const payload: CreateProductRequest = {
name: name.trim(),
category: category.trim(),
source: product?.source ?? 'custom',
selling_points: sellingPointsText.split('\n').map((s) => s.trim()).filter(Boolean),
style_tone: styleTone.trim(),
text_angles: product?.text_angles ?? [],
custom_prompt: product?.custom_prompt ?? null,
banned_word_ids: product?.banned_word_ids ?? [],
brand_keyword: brandKeyword.trim() || null,
target_audience: targetAudience.trim() || null,
};
try {
const saved = isEdit
? await api.put<Product>(`/api/v1/products/${product!.id}`, payload)
: await api.post<Product>('/api/v1/products', payload);
onSaved(saved);
} catch (err) {
setError((err as { message?: string })?.message || '保存失败,请重试');
} finally {
setSaving(false);
}
}
const inputCls =
'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange';
return (
<form onSubmit={handleSave}
className="border border-brand-orange/30 rounded-xl p-5 bg-brand-cream/40 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-text-primary">{isEdit ? '编辑产品档案' : '新增产品'}</h3>
<button type="button" onClick={onCancel}
className="text-xs text-text-tertiary hover:text-text-secondary"></button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="pf-name" className="block text-xs text-text-secondary mb-1"> *</label>
<input id="pf-name" value={name} onChange={(e) => setName(e.target.value)}
maxLength={64} className={inputCls} required aria-required="true" />
</div>
<div>
<label htmlFor="pf-cat" className="block text-xs text-text-secondary mb-1"></label>
<input id="pf-cat" value={category} onChange={(e) => setCategory(e.target.value)}
maxLength={64} placeholder="如:护肤-素颜霜" className={inputCls} />
</div>
<div>
<label htmlFor="pf-brand" className="block text-xs text-text-secondary mb-1">+</label>
<input id="pf-brand" value={brandKeyword} onChange={(e) => setBrandKeyword(e.target.value)}
maxLength={32} placeholder="如:倍分子" className={inputCls} />
</div>
<div>
<label htmlFor="pf-aud" className="block text-xs text-text-secondary mb-1"></label>
<input id="pf-aud" value={targetAudience} onChange={(e) => setTargetAudience(e.target.value)}
maxLength={128} placeholder="如:黄黑皮、熬夜党" className={inputCls} />
</div>
</div>
<div>
<label htmlFor="pf-style" className="block text-xs text-text-secondary mb-1"></label>
<input id="pf-style" value={styleTone} onChange={(e) => setStyleTone(e.target.value)}
maxLength={128} placeholder="如:真实分享、闺蜜种草" className={inputCls} />
</div>
<div>
<label htmlFor="pf-sp" className="block text-xs text-text-secondary mb-1">
</label>
<textarea id="pf-sp" value={sellingPointsText} onChange={(e) => setSellingPointsText(e.target.value)}
rows={5} placeholder={'烟酰胺改善暗沉,自然提亮\n水润奶油质地丝滑好涂开\n上脸不卡纹不假白'}
className={inputCls + ' resize-y'} />
</div>
{error && <p role="alert" className="text-xs text-status-error">{error}</p>}
<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 ? '保存中…' : (isEdit ? '保存修改' : '保存')}
</button>
</div>
</form>
);
}

View File

@@ -94,9 +94,17 @@ export function ProductImageManager({
{images.map((im: ProductImage) => (
<div key={im.id}
className="flex items-center gap-2 border border-border-default rounded-lg p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={toUploadSrc(im.path)} alt={`${sceneLabel(im.scene)}`}
className="h-12 w-12 object-cover rounded border border-border-default shrink-0" />
<div className="relative shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={toUploadSrc(im.path)} alt={`${sceneLabel(im.scene)}`}
className="h-20 w-20 object-cover rounded border border-border-default" />
{/* 图上叠当前场景徽章:放大缩略图+一眼可辨,防止瓶身/质地图标反 */}
<span className={`absolute bottom-0 left-0 right-0 text-[10px] text-center
py-0.5 rounded-b text-white truncate
${im.is_primary ? 'bg-brand-orange/90' : 'bg-black/60'}`}>
{sceneLabel(im.scene)}
</span>
</div>
<div className="flex-1 min-w-0 space-y-1">
<select value={im.scene} disabled={busyId === im.id}
onChange={(e) => changeScene(im.id, e.target.value)}
@@ -140,11 +148,16 @@ export function ProductImageManager({
</button>
</div>
{images.length === 0 && (
{images.length === 0 ? (
<p className="text-xs text-text-tertiary leading-relaxed">
/
</p>
) : (
<p className="text-xs text-amber-600 leading-relaxed">
/
/
</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp"

View File

@@ -1,12 +1,14 @@
'use client';
/**
* ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件
* ProductCard + ProductsSkeleton — 产品档案子组件(编辑/新建统一走 ProductFormFull
* 产品多图管理拆到 ProductImageManagerR5
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { Product, CreateProductRequest } from '@/types/index';
import { useAuthStore } from '@/stores/authStore';
import { Product } from '@/types/index';
import { ProductImageManager } from './ProductImageManager';
import { ProductFormFull } from './ProductFormFull';
// ProductImageUpload 旧单图组件已被 ProductImageManager多图+场景)取代。
// 保留导出名向后兼容引用方。
@@ -14,14 +16,48 @@ export { ProductImageManager as ProductImageUpload };
export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) {
const [current, setCurrent] = useState<Product>(product);
const role = useAuthStore((s) => s.role);
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const [editing, setEditing] = useState(false);
const [error, setError] = useState('');
async function handleDelete() {
setDeleting(true); setError('');
try {
await api.delete(`/api/v1/products/${current.id}`);
setConfirming(false);
onUpdated();
} catch (err) {
setError((err as { message?: string })?.message || '删除失败,请重试');
} finally {
setDeleting(false);
}
}
if (editing) {
return (
<ProductFormFull
product={current}
onSaved={(p) => { setCurrent(p); setEditing(false); onUpdated(); }}
onCancel={() => setEditing(false)}
/>
);
}
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 className="flex items-center gap-2">
<button onClick={() => setEditing(true)}
className="text-xs text-brand-orange border border-brand-orange/40 rounded-lg px-2 py-0.5 hover:bg-brand-orange/10">
</button>
<span className="text-xs text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{current.category}
</span>
</div>
</div>
<p className="text-xs text-text-secondary line-clamp-2">
{current.selling_points.join(' · ')}
@@ -37,70 +73,37 @@ export function ProductCard({ product, onUpdated }: { product: Product; onUpdate
product={current}
onChanged={(updated) => { setCurrent(updated); onUpdated(); }}
/>
{role === 'admin' && (
<div className="pt-2 border-t border-border-default">
{!confirming ? (
<button onClick={() => setConfirming(true)}
className="text-xs text-status-error border border-status-error rounded-lg px-3 py-1 hover:bg-red-50">
</button>
) : (
<div className="space-y-2">
<p className="text-xs text-text-secondary">
{current.name}
</p>
{error && <p role="alert" className="text-xs text-status-error">{error}</p>}
<div className="flex gap-2">
<button onClick={() => { setConfirming(false); setError(''); }} disabled={deleting}
className="text-xs text-text-secondary border border-border-default rounded-lg px-3 py-1 hover:bg-surface-tertiary disabled:opacity-50">
</button>
<button onClick={handleDelete} disabled={deleting}
className="text-xs text-white bg-status-error rounded-lg px-3 py-1 hover:opacity-90 disabled:opacity-50">
{deleting ? '删除中…' : '确认删除'}
</button>
</div>
</div>
)}
</div>
)}
</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);
const [error, setError] = useState('');
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) { setError('请填写产品名称'); return; }
setSaving(true); setError('');
try {
await api.post('/api/v1/products', form);
onSaved();
} catch (err) {
const msg = (err as { message?: string })?.message;
setError(msg || '保存失败,请重试');
} 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>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<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="加载产品档案中">

View File

@@ -7,7 +7,8 @@ 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';
import { ProductCard, ProductsSkeleton } from './ProductSubComponents';
import { ProductFormFull } from './ProductFormFull';
export function ProductsTab() {
const [products, setProducts] = useState<Product[]>([]);
@@ -70,7 +71,7 @@ export function ProductsTab() {
)}
{showForm && (
<ProductForm
<ProductFormFull
onSaved={() => { setShowForm(false); loadProducts(); }}
onCancel={() => setShowForm(false)}
/>

View File

@@ -0,0 +1,168 @@
'use client';
/**
* UserManageTab — 系统管理 → 账号管理(仅 admin
* 列出本 workspace 成员,建号,启用/禁用(软删除,可恢复)。
* 倩倩姐2026-06-30禁用=软删除随时恢复,不物理删。
*/
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { ApiError } from '@/types';
import { CreateUserModal } from './CreateUserModal';
interface WsUser {
id: number;
username: string;
email: string;
role: string;
is_active: boolean;
must_change_password: boolean;
last_login_at: string | null;
}
const ROLE_LABELS: Record<string, string> = {
admin: '管理员', supervisor: '组长', operator: '运营',
};
function UserTable({ users, onToggle, onDelete, roleLabels }: {
users: WsUser[];
onToggle: (u: WsUser) => void;
onDelete: (u: WsUser) => void;
roleLabels: Record<string, string>;
}) {
if (users.length === 0) return <p className="text-sm text-text-tertiary"></p>;
return (
<table className="w-full text-sm border border-border-default rounded-lg overflow-hidden">
<thead className="bg-surface-tertiary text-text-secondary">
<tr>
<th className="text-left px-3 py-2 font-medium"></th>
<th className="text-left px-3 py-2 font-medium"></th>
<th className="text-left px-3 py-2 font-medium"></th>
<th className="text-left px-3 py-2 font-medium"></th>
<th className="text-right px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-border-default">
<td className="px-3 py-2 text-text-primary">
{u.username}
{u.must_change_password && <span className="ml-2 text-xs text-brand-orange"></span>}
</td>
<td className="px-3 py-2 text-text-secondary">{roleLabels[u.role] || u.role}</td>
<td className="px-3 py-2">
{u.is_active
? <span className="text-status-success"></span>
: <span className="text-text-tertiary"></span>}
</td>
<td className="px-3 py-2 text-text-tertiary text-xs">
{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '从未登录'}
</td>
<td className="px-3 py-2 text-right">
<button onClick={() => onToggle(u)}
className={`text-xs px-3 py-1 rounded-md border transition-colors ${
u.is_active
? 'border-border-default text-text-secondary hover:bg-surface-tertiary'
: 'border-brand-orange text-brand-orange hover:bg-brand-orange-light'
}`}>
{u.is_active ? '禁用' : '恢复'}
</button>
<button onClick={() => onDelete(u)}
className="ml-2 text-xs px-3 py-1 rounded-md border border-status-error text-status-error hover:bg-red-50 transition-colors">
</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
export function UserManageTab() {
const [users, setUsers] = useState<WsUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [pendingDelete, setPendingDelete] = useState<WsUser | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await api.get<WsUser[]>('/api/v1/users');
setUsers(res || []);
} catch (err) {
setError((err as ApiError)?.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
async function toggleActive(u: WsUser) {
try {
await api.post(`/api/v1/users/${u.id}/active`, { is_active: !u.is_active });
await load();
} catch (err) {
setError((err as ApiError)?.message || '操作失败');
}
}
async function confirmDelete() {
if (!pendingDelete) return;
try {
await api.delete(`/api/v1/users/${pendingDelete.id}`);
setPendingDelete(null);
await load();
} catch (err) {
setError((err as ApiError)?.message || '删除失败');
setPendingDelete(null);
}
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-text-secondary"></p>
<button onClick={() => setShowCreate(true)}
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600">
+
</button>
</div>
{error && <div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg mb-3">{error}</div>}
{loading ? (
<p className="text-sm text-text-tertiary"></p>
) : (
<UserTable users={users} onToggle={toggleActive} onDelete={setPendingDelete} roleLabels={ROLE_LABELS} />
)}
{pendingDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface-primary rounded-xl p-6 w-[360px] shadow-xl">
<h3 className="text-base font-medium text-text-primary mb-2"></h3>
<p className="text-sm text-text-secondary mb-1">
<span className="font-medium text-text-primary">{pendingDelete.username}</span>
</p>
<p className="text-xs text-status-error mb-5">使</p>
<div className="flex justify-end gap-3">
<button onClick={() => setPendingDelete(null)}
className="text-sm px-4 py-2 rounded-lg border border-border-default text-text-secondary hover:bg-surface-tertiary">
</button>
<button onClick={confirmDelete}
className="text-sm px-4 py-2 rounded-lg bg-status-error text-white hover:bg-red-600">
</button>
</div>
</div>
</div>
)}
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={() => { setShowCreate(false); load(); }}
/>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { api } from '@/lib/api';
import { TaskListItem } from '@/types';
import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx';
import { FissionSourceCard } from './FissionSourceCard';
interface Props {
sources: TaskListItem[];
@@ -48,18 +49,22 @@ export function FissionLauncher({ sources, onLaunched }: Props) {
<div className="flex flex-col gap-5">
{/* 选源爆款 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<select
value={sourceId ?? ''}
onChange={(e) => setSourceId(Number(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"
>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-xs text-text-tertiary font-normal ml-2">
/ N
</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-72 overflow-auto pr-1">
{sources.map((t) => (
<option key={t.id} value={t.id}>
#{t.id} · {t.theme || '(无主题)'}
</option>
<FissionSourceCard
key={t.id}
task={t}
selected={sourceId === t.id}
onSelect={() => setSourceId(t.id)}
/>
))}
</select>
</div>
</div>
{/* 参考强度 */}

View File

@@ -1,17 +1,18 @@
'use client';
/**
* FissionProgress — 裂变进度(轮询 GET /fission/{id} 聚合 x/N 完成
* 新架构:一次 LLM 出 N 套完整笔记包落 FissionNote完成后进独立展示页看 N 套。
* FissionProgress — 裂变进度(A3
* 保持轮询机制,用轮询返回的 done/total + 起始时间戳推算阶段和 ETA
*/
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';
import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
interface FissionStatus {
fission_id: number;
reference_level: string;
fanout_count: number;
status: 'generating' | 'completed' | 'failed';
status: 'generating' | 'completed' | 'completed_with_fallback' | 'completed_fb' | 'failed';
progress: { done: number; failed: number; total: number };
}
@@ -20,9 +21,36 @@ interface Props {
onReset: () => void;
}
const FISSION_STAGES = [
{ label: '解析爆款结构' },
{ label: '提炼卖点与人群' },
{ label: '撰写标题正文标签' },
{ label: '规划叙事链路和图文分镜' },
{ label: '生成图片与质检' },
{ label: '整理完整交付包' },
];
/** 完成判定:兼容新短码 completed_fb 与历史旧值 completed_with_fallback */
const isFissionDone = (s: string) =>
s === 'completed' || s === 'completed_fb' || s === 'completed_with_fallback';
/** 是否含品类兜底草稿(需人工复核提示) */
const isFissionFallback = (s: string) =>
s === 'completed_fb' || s === 'completed_with_fallback';
/** 根据 done/total 推算裂变阶段 */
function calcFissionStageIdx(done: number, total: number, status: string): number {
if (isFissionDone(status)) return FISSION_STAGES.length;
if (done === 0) return 1; // 提炼卖点
if (done < total * 0.3) return 2; // 撰写文案
if (done < total * 0.6) return 3; // 规划叙事
if (done < total) return 4; // 生成图片
return 5; // 整理交付包
}
export function FissionProgress({ fissionId, onReset }: Props) {
const [data, setData] = useState<FissionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const startedAtRef = useRef<number>(Date.now());
const poll = useCallback(async () => {
try {
@@ -36,6 +64,7 @@ export function FissionProgress({ fissionId, onReset }: Props) {
}, [fissionId]);
useEffect(() => {
startedAtRef.current = Date.now();
let timer: ReturnType<typeof setTimeout>;
let stopped = false;
const tick = async () => {
@@ -58,7 +87,8 @@ export function FissionProgress({ fissionId, onReset }: Props) {
if (!data) return <div className="py-12 text-center text-text-tertiary text-sm"></div>;
const { progress, status } = data;
const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
const activeIdx = calcFissionStageIdx(progress.done, progress.total, status);
const allDone = isFissionDone(status);
return (
<div className="flex flex-col gap-4">
@@ -66,28 +96,31 @@ export function FissionProgress({ fissionId, onReset }: Props) {
<h2 className="text-base font-semibold text-text-primary">
#{data.fission_id} · {data.fanout_count}
</h2>
<span className="text-sm text-text-secondary">
{progress.done}/{progress.total || data.fanout_count}
{progress.failed > 0 && <span className="text-red-500"> · {progress.failed} </span>}
</span>
{progress.failed > 0 && (
<span className="text-sm text-red-500">{progress.failed} </span>
)}
</div>
<div className="w-full h-2 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-brand-orange transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<StageLoadingPanel
title={allDone ? `已生成 ${data.fanout_count} 套笔记包` : `正在裂变 ${progress.done}/${progress.total || data.fanout_count}`}
stages={FISSION_STAGES}
activeIdx={activeIdx}
targetSec={95}
startedAt={startedAtRef.current}
className=""
/>
{status === 'generating' && (
<p className="text-sm text-text-tertiary"> {data.fanout_count} </p>
)}
{status === 'failed' && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-600">
</div>
)}
{status === 'completed' && (
{isFissionFallback(status) && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-700">
使稿
</div>
)}
{allDone && (
<Link
href={`/fission/${data.fission_id}/notes`}
className="self-start rounded-lg bg-brand-orange text-white px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
@@ -95,7 +128,6 @@ export function FissionProgress({ fissionId, onReset }: Props) {
{data.fanout_count}
</Link>
)}
<button
type="button"
onClick={onReset}

View File

@@ -0,0 +1,91 @@
'use client';
/**
* FissionSourceCard — 裂变源爆款卡片(#8 源爆款看不清/无图)
* 富化展示:封面缩略图(懒拉首张已选图) + 主题 + 产品 + 文/图条数 + 状态徽章。
* 替代原来干巴巴的下拉框,让运营看清"在拿哪条爆款去裂变"。
*/
import { useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { TaskListItem } from '@/types';
import { clsx } from 'clsx';
function toImgSrc(url: string) {
if (!url) return '';
if (/^https?:\/\//.test(url)) return url;
return '/uploads/' + url.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '');
}
export function FissionSourceCard({
task, selected, onSelect,
}: {
task: TaskListItem;
selected: boolean;
onSelect: () => void;
}) {
const [cover, setCover] = useState<string>('');
const [loadingCover, setLoadingCover] = useState(true);
const ref = useRef<HTMLButtonElement>(null);
const [inView, setInView] = useState(false);
// 卡片进视口才拉封面,避免一次性 50 条全并发 GET 造成请求风暴(交叉验证#5)
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) { setInView(true); io.disconnect(); }
}, { rootMargin: '100px' });
io.observe(el);
return () => io.disconnect();
}, []);
// 懒拉封面:取该任务首张已选图(没有则第一张)。列表接口无封面字段,按需单查。
useEffect(() => {
if (!inView) return;
let alive = true;
api.get<{ image_candidates?: { url: string; is_selected?: boolean }[] }>(`/api/v1/tasks/${task.id}`)
.then((d) => {
if (!alive) return;
const imgs = d.image_candidates || [];
const pick = imgs.find((i) => i.is_selected) || imgs[0];
setCover(pick ? toImgSrc(pick.url) : '');
})
.catch(() => {})
.finally(() => { if (alive) setLoadingCover(false); });
return () => { alive = false; };
}, [inView, task.id]);
return (
<button ref={ref} type="button" onClick={onSelect}
className={clsx(
'flex gap-3 w-full text-left border rounded-lg p-3 transition-colors',
selected ? 'border-brand-orange bg-brand-orange-light' : 'border-border-default hover:border-brand-orange',
)}
aria-pressed={selected}
>
<div className="w-16 h-16 rounded-md bg-surface-tertiary shrink-0 overflow-hidden flex items-center justify-center">
{cover ? (
<img src={cover} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xs text-text-tertiary">{loadingCover ? '…' : '无图'}</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-text-primary truncate">
{task.theme || '(无主题)'}
</div>
<div className="text-xs text-text-tertiary mt-0.5 truncate">
{task.product_name || `产品#${task.product_id}`} · #{task.id}
</div>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-xs text-text-secondary">{task.text_count} · {task.image_count} </span>
<span className={clsx(
'text-[10px] px-1.5 py-0.5 rounded',
task.status === 'approved' ? 'bg-green-50 text-green-600' : 'bg-surface-tertiary text-text-tertiary',
)}>
{task.status === 'approved' ? '已通过' : '已归档'}
</span>
</div>
</div>
</button>
);
}

View File

@@ -5,7 +5,7 @@
* 角色守卫:/review 只组长+管理员;/settings 全员;/config 只管理员
*/
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { usePathname, useSearchParams } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
import { clsx } from 'clsx';
@@ -14,16 +14,21 @@ interface NavItem {
href: string;
roles?: string[];
icon: string;
// 同一 pathname 下用 query 区分高亮(图片排版/历史归档都落 /history
view?: string;
}
// 三并列顶层创作入口倩倩姐2026-06-15拍板"三并列入口"
// 文案创作=开新任务;图片排版=历史任务列表里挑一条进图片步骤(图片排版必须有 task id,
// 故落到 /history,每行提供"去排图"入口,不造无 task-id 的死链);裂变扩散=独立裂变页。
// 图片排版与历史归档同落 /history靠 ?view=images 区分页面标题+高亮(修#7名实不符
const NAV_ITEMS: NavItem[] = [
{ label: '工作台', href: '/dashboard', icon: '🏠' },
{ label: '任务看板', href: '/board', icon: '📋' },
{ label: '文案创作', href: '/tasks/new', icon: '✏️' },
{ label: '图片排版', href: '/history', icon: '🖼️' },
// 「图片排版」暂时隐藏倩倩姐2026-06-26当前它与「历史归档」同落 /history、
// 仅靠 ?view=images 区分,界面一模一样无独立价值。待重新设计图片排版界面后再放开。
// { label: '图片排版', href: '/history?view=images', icon: '🖼️', view: 'images' },
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
{ label: '历史归档', href: '/history', icon: '🗂️' },
@@ -33,7 +38,9 @@ const NAV_ITEMS: NavItem[] = [
export function Sidebar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const { role } = useAuthStore();
const currentView = searchParams.get('view');
const visible = NAV_ITEMS.filter(
(item) => !item.roles || (role && item.roles.includes(role))
@@ -45,7 +52,12 @@ export function Sidebar() {
aria-label="主导航"
>
{visible.map((item) => {
const active = pathname === item.href || pathname.startsWith(item.href + '/');
const base = item.href.split('?')[0];
// 同 pathname 下用 view 区分:图片排版(view=images) vs 历史归档(无 view)
const pathMatch = pathname === base || pathname.startsWith(base + '/');
const active = base === '/history'
? pathMatch && (item.view ?? null) === currentView
: pathMatch;
return (
<Link
key={item.label}

View File

@@ -2,7 +2,9 @@
/**
* UserBadge — 顶部右上角当前用户
* 只显示名称和角色,不显示 token 余额
* 点击展开菜单:修改密码 / 退出倩倩姐2026-06-30改密要有主动入口
*/
import { useState, useRef, useEffect } from 'react';
import { useAuthStore } from '@/stores/authStore';
import { useRouter } from 'next/navigation';
@@ -15,6 +17,16 @@ const ROLE_LABELS: Record<string, string> = {
export default function UserBadge() {
const { user, role, logout } = useAuthStore();
const router = useRouter();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
if (!user) return null;
@@ -24,17 +36,32 @@ export default function UserBadge() {
}
return (
<div className="flex items-center gap-3">
<span className="text-white text-sm">
{role ? ROLE_LABELS[role] : ''} · {user.username}
</span>
<div className="relative" ref={ref}>
<button
onClick={handleLogout}
aria-label="退出登录"
className="text-white/80 text-xs hover:text-white transition-colors"
onClick={() => setOpen((v) => !v)}
aria-label="用户菜单"
aria-expanded={open}
className="flex items-center gap-2 text-white text-sm hover:text-white/90 transition-colors"
>
退
<span>{role ? ROLE_LABELS[role] : ''} · {user.username}</span>
<span aria-hidden="true" className="text-xs"></span>
</button>
{open && (
<div className="absolute right-0 mt-2 w-40 bg-surface-primary rounded-lg shadow-lg border border-border-default py-1 z-50">
<button
onClick={() => { setOpen(false); router.push('/change-password'); }}
className="w-full text-left px-4 py-2 text-sm text-text-primary hover:bg-surface-tertiary transition-colors"
>
🔑
</button>
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
>
退
</button>
</div>
)}
</div>
);
}

View File

@@ -13,6 +13,7 @@ interface ReviewCardProps {
}
export function ReviewCard({ item, onApprove, onReject }: ReviewCardProps) {
const sets = item.selected_sets?.filter((s) => s.selected_text || s.selected_images.length) ?? [];
return (
<article
className="border border-border-default rounded-xl bg-surface-primary p-4"
@@ -26,22 +27,57 @@ export function ReviewCard({ item, onApprove, onReject }: ReviewCardProps) {
<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 && (
{sets.length === 0 && item.selected_text && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">
{item.selected_text.content}
</p>
)}
{/* 质量分:审核可判断(C2)。合格线80=红线,不用组件默认85 */}
{item.selected_text?.score && (
{sets.length === 0 && item.selected_text?.score && (
<ScoreDimBars score={item.selected_text.score} passLine={80} />
)}
</div>
{item.selected_image && (
{sets.length === 0 && item.selected_image && (
<img src={item.selected_image.url} alt="已选图预览"
className="w-16 h-16 rounded-lg object-cover flex-shrink-0" />
)}
</div>
{sets.length > 0 && (
<div className="mt-4 grid gap-3">
{sets.map((set) => (
<section key={set.strategy} className="rounded-lg border border-border-default p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-semibold text-brand-orange">{set.strategy}</span>
<span className="text-xs text-text-tertiary">{set.selected_images.length} </span>
</div>
{set.selected_text && (
<>
<p className="text-xs text-text-secondary line-clamp-2">
{set.selected_text.content}
</p>
{set.selected_text.score && (
<ScoreDimBars score={set.selected_text.score} passLine={80} />
)}
</>
)}
{set.selected_images.length > 0 && (
<div className="mt-2 flex gap-2 overflow-x-auto">
{set.selected_images.filter(Boolean).map((img) => (
<img
key={img!.candidate_id}
src={img!.url}
alt={`${set.strategy}已选图`}
className="h-16 w-12 flex-shrink-0 rounded-md object-cover"
/>
))}
</div>
)}
</section>
))}
</div>
)}
{/* 操作栏 — 飞轮最强信号 */}
<div className="flex gap-3 mt-4 pt-4 border-t border-border-default">
<button onClick={onApprove}

View File

@@ -5,12 +5,15 @@
* [编辑配置]→侧滑面板原地编辑,改完实时刷新预览
*/
import { Product } from '@/types/index';
import { useAuthStore } from '@/stores/authStore';
interface ConfigPreviewPanelProps {
product: Product;
onEditInline: () => void; // 非管理员(运营/组长):就地编辑当前产品,不离开文案创作页
}
export function ConfigPreviewPanel({ product }: ConfigPreviewPanelProps) {
export function ConfigPreviewPanel({ product, onEditInline }: ConfigPreviewPanelProps) {
const { role } = useAuthStore();
return (
<aside
className="w-64 border-l border-border-default bg-surface-primary p-4 overflow-auto flex-shrink-0"
@@ -18,8 +21,13 @@ export function ConfigPreviewPanel({ product }: ConfigPreviewPanelProps) {
>
<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>
{role === 'admin' ? (
<a href="/settings?tab=products" aria-label="前往产品档案管理页"
className="text-xs text-brand-orange hover:underline"></a>
) : (
<button type="button" onClick={onEditInline} aria-label="就地编辑当前产品配置"
className="text-xs text-brand-orange hover:underline"></button>
)}
</div>
<div className="space-y-3 text-sm">

View File

@@ -4,18 +4,25 @@ interface ConfirmActionBarProps {
submitting: boolean;
regenerating: boolean;
canSubmit: boolean;
hydrating?: boolean; // C1: hydrate 期间显示加载态文字,避免无提示灰死
onSubmit: () => void;
onRegenerate: () => void;
}
export function ConfirmActionBar({
submitting, regenerating, canSubmit, onSubmit, onRegenerate,
submitting, regenerating, canSubmit, hydrating = false, onSubmit, onRegenerate,
}: ConfirmActionBarProps) {
const submitLabel = hydrating
? '正在还原选中…'
: submitting
? '提交中…'
: '提交审核';
return (
<div className="flex gap-3 mt-8">
<button
onClick={onRegenerate}
disabled={regenerating || submitting}
disabled={regenerating || submitting || hydrating}
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"
>
@@ -23,11 +30,12 @@ export function ConfirmActionBar({
</button>
<button
onClick={onSubmit}
disabled={submitting || regenerating || !canSubmit}
disabled={submitting || regenerating || !canSubmit || hydrating}
aria-label="提交审核"
title={!canSubmit && !hydrating ? '请至少选择 1 条文案和 1 张图片' : undefined}
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 ? '提交中…' : '提交审核'}
{submitLabel}
</button>
</div>
);

View File

@@ -4,9 +4,10 @@ import { ImageCandidate } from '@/types/index';
interface ConfirmPreviewImagesProps {
images: ImageCandidate[];
onZoom?: (img: ImageCandidate) => void; // 传入则图片可点击放大(已通过只读查看用)
}
export function ConfirmPreviewImages({ images }: ConfirmPreviewImagesProps) {
export function ConfirmPreviewImages({ images, onZoom }: ConfirmPreviewImagesProps) {
return (
<div>
<h3 className="text-sm font-semibold text-text-secondary mb-3"></h3>
@@ -15,7 +16,11 @@ export function ConfirmPreviewImages({ images }: ConfirmPreviewImagesProps) {
) : (
<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">
<div
key={img.candidate_id}
className={`rounded-xl overflow-hidden border border-border-default ${onZoom ? 'cursor-zoom-in' : ''}`}
onClick={onZoom ? () => onZoom(img) : undefined}
>
<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>

View File

@@ -0,0 +1,66 @@
'use client';
/**
* DeleteTaskButton — 任务详情页右上角「删除整个任务」按钮倩倩姐2026-06-26拍板
* 仅管理员可见;点击先弹确认,确认后调 DELETE /tasks/{id}。
* 后端智能删:有成品→转归档保留,残次→物理删。删除成功跳回看板。
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
export function DeleteTaskButton({ taskId }: { taskId: number }) {
const isAdmin = useAuthStore((s) => s.role) === 'admin';
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const [err, setErr] = useState('');
if (!isAdmin) return null;
async function handleDelete() {
setDeleting(true);
setErr('');
try {
await api.delete(`/api/v1/tasks/${taskId}`);
router.push('/board');
} catch (e: unknown) {
const ae = e as { message?: string };
setErr(ae.message || '删除失败');
setDeleting(false);
}
}
if (!confirming) {
return (
<button
onClick={() => setConfirming(true)}
className="rounded-lg border border-red-200 px-3 py-1.5 text-sm text-red-500 hover:bg-red-50"
aria-label="删除整个任务"
>
</button>
);
}
return (
<div className="flex items-center gap-2">
{err && <span className="text-xs text-red-500">{err}</span>}
<span className="text-sm text-text-secondary"></span>
<button
onClick={() => setConfirming(false)}
disabled={deleting}
className="rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-secondary disabled:opacity-50"
>
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="rounded-lg bg-red-500 px-3 py-1.5 text-sm text-white hover:bg-red-600 disabled:opacity-50"
>
{deleting ? '删除中…' : '确认删除'}
</button>
</div>
);
}

View File

@@ -1,38 +1,65 @@
'use client';
/**
* ImageProgressHeader — 生图进度标题 + 进度条
* 用于屏4"挑图"顶部,能离开提示
* ImageProgressHeader — 生图多步骤阶段进度A1
* 数据来自 generationStore用真实 SSE imageDone 推算当前阶段
*/
interface ImageProgressHeaderProps {
import { useGenerationStore } from '@/stores/generationStore';
import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
const IMAGE_STAGES = [
{ label: '整理产品图和参考图' },
{ label: '规划点击-证明-转化分镜' },
{ label: '逐张生成 3:4 图片' },
{ label: '检查是否出现 App 截图/底栏' },
{ label: '整理图片预览和同步包' },
];
// 根据 imageDone/imageTotal 推算当前激活阶段
function calcImageStageIdx(done: number, total: number, stage: string): number {
if (stage === 'idle' || stage === 'analyzing' || stage === 'text') return 0;
if (stage === 'done') return IMAGE_STAGES.length; // 全完成
// 生图阶段0=整理图, 1=规划分镜, 2=逐张生成(主阶段), 3=检查, 4=整理包
if (done === 0) return 1; // 还没出图:规划阶段
if (done < total) return 2; // 出图中:逐张生成
if (done >= total && total > 0) return 4; // 全出完:整理包(最后一步)
return 2;
}
interface Props {
imageTotal: number;
imageDone: number;
pendingCount: number;
}
export function ImageProgressHeader({ imageTotal, imageDone, pendingCount }: ImageProgressHeaderProps) {
const pct = imageTotal > 0 ? Math.round((imageDone / imageTotal) * 100) : 0;
export function ImageProgressHeader({ imageTotal, imageDone, pendingCount }: Props) {
const { stage, startedAt } = useGenerationStore();
const allDone = imageTotal > 0 && pendingCount === 0;
const activeIdx = calcImageStageIdx(imageDone, imageTotal, stage);
// 真实进度比例(出完再跳到 100%
const realPct = allDone
? 100
: imageTotal > 0
? Math.min(96, Math.round((imageDone / imageTotal) * 85 + 14))
: undefined;
// 骨架屏:还未收到图时展示占位
const skeletonCount = imageCandidatesEmpty(imageDone) ? 6 : 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>
)}
</>
<StageLoadingPanel
title={allDone ? `已生成 ${imageTotal} 张图` : `正在生成图片 ${imageDone}/${imageTotal || '…'}`}
stages={IMAGE_STAGES}
activeIdx={activeIdx}
targetSec={62}
startedAt={startedAt}
realPct={realPct}
skeletonCount={skeletonCount}
className="mb-4"
/>
);
}
function imageCandidatesEmpty(done: number) {
return done === 0;
}

View File

@@ -1,6 +1,8 @@
'use client';
// ImageStrategyGroup — 按"套"(A/B/C)分组展示挑图,组内按 seq 固定6图序点图放大
import { useState } from 'react';
import { ImageCandidate } from '@/types/index';
import { useAuthStore } from '@/stores/authStore';
// 套别中文标签(三套正交叙事:痛点先行/场景先行/成分背书先行)
export const STRATEGY_LABEL: Record<string, string> = {
@@ -32,11 +34,29 @@ interface ImageStrategyGroupProps {
onToggle: (id: number) => void;
onZoom: (c: ImageCandidate) => void;
onRegen?: (strategy: string, role: string | undefined, label: string) => void;
regeneratingKeys?: Set<string>;
onDelete?: (id: number) => Promise<void>;
}
export function ImageStrategyGroup({
strategy, candidates, selectedImageIds, onToggle, onZoom, onRegen,
strategy, candidates, selectedImageIds, onToggle, onZoom, onRegen, regeneratingKeys, onDelete,
}: ImageStrategyGroupProps) {
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
// 删除权限仅管理员倩倩姐2026-06-30拍板
const isAdmin = useAuthStore((s) => s.role) === 'admin';
async function handleDelete(candidateId: number) {
if (!onDelete || deletingId !== null) return;
setDeletingId(candidateId);
try {
await onDelete(candidateId);
} finally {
setDeletingId(null);
setConfirmDeleteId(null);
}
}
// 组内按 seq 升序北哥固定序seq 缺失的排末尾
const ordered = [...candidates].sort(
(a, b) => (a.seq ?? 999) - (b.seq ?? 999)
@@ -52,9 +72,10 @@ export function ImageStrategyGroup({
{onRegen && (
<button
onClick={() => onRegen(strategy, undefined, STRATEGY_LABEL[strategy] || `${strategy}`)}
disabled={regeneratingKeys?.has(`${strategy}:*`)}
className="ml-auto rounded-md border border-border-default px-2 py-0.5 text-xs font-normal text-text-secondary hover:border-brand-orange hover:text-brand-orange"
>
{regeneratingKeys?.has(`${strategy}:*`) ? '重生中…' : '⟳ 重生整套'}
</button>
)}
</h3>
@@ -74,10 +95,11 @@ export function ImageStrategyGroup({
{onRegen && (
<button
onClick={() => onRegen(strategy, c.role, ROLE_LABEL[c.role] || c.role)}
disabled={regeneratingKeys?.has(`${strategy}:${c.role}`) || regeneratingKeys?.has(`${strategy}:*`)}
aria-label={`重生 ${ROLE_LABEL[c.role] || c.role}`}
className="absolute bottom-9 right-1.5 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-brand-orange"
className="absolute bottom-9 right-1.5 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-brand-orange disabled:cursor-not-allowed disabled:bg-black/35"
>
{regeneratingKeys?.has(`${strategy}:${c.role}`) || regeneratingKeys?.has(`${strategy}:*`) ? '重生中…' : '⟳ 重生'}
</button>
)}
<img
@@ -95,6 +117,35 @@ export function ImageStrategyGroup({
AI {Math.round(c.ai_visual_score)}
</span>
)}
{/* 删除按钮AI分下方确认遮罩仅管理员 */}
{onDelete && isAdmin && confirmDeleteId !== c.candidate_id && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmDeleteId(c.candidate_id); }}
aria-label={`删除 ${ROLE_LABEL[c.role] || c.role}`}
className="absolute right-1.5 top-7 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-status-error"
>
</button>
)}
{onDelete && confirmDeleteId === c.candidate_id && (
<div
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-xl bg-black/70"
onClick={(e) => e.stopPropagation()}
>
<p className="text-[11px] text-white text-center px-1"></p>
<div className="flex gap-1.5">
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDeleteId(null); }}
className="text-[11px] bg-white/20 text-white rounded px-2 py-0.5 hover:bg-white/30">
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); handleDelete(c.candidate_id); }}
disabled={deletingId === c.candidate_id}
className="text-[11px] bg-status-error text-white rounded px-2 py-0.5 hover:opacity-90 disabled:opacity-50">
{deletingId === c.candidate_id ? '…' : '删除'}
</button>
</div>
</div>
)}
<button
onClick={() => onToggle(c.candidate_id)}
aria-label={`${selected ? '取消选择' : '选择'} ${ROLE_LABEL[c.role] || c.role}`}

View File

@@ -4,12 +4,18 @@
* 选产品 + 今天主题 + 数量 + 双轨入口
*/
import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
import { useState } from 'react';
import { CountStepper } from './CountStepper';
import { QuickProductCreate } from './QuickProductCreate';
import { ProductEditPanel } from './ProductEditPanel';
interface NewTaskFormProps {
products: Product[];
selectedProduct: Product | null;
onSelectProduct: (p: Product | null) => void;
onProductCreated: (p: Product) => void;
editingProduct: boolean; // 受控:就地编辑当前产品开关(提升到页面,供右侧「编辑配置」触发)
setEditingProduct: (v: boolean) => void;
benchmarks: Benchmark[];
form: Omit<CreateTaskRequest, 'product_id'>;
onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void;
@@ -20,17 +26,19 @@ interface NewTaskFormProps {
}
export function NewTaskForm({
products, selectedProduct, onSelectProduct, benchmarks,
products, selectedProduct, onSelectProduct, onProductCreated,
editingProduct, setEditingProduct, benchmarks,
form, onFormChange, submitting, error,
onSubmitAi, onSubmitImport,
}: NewTaskFormProps) {
const [creatingNew, setCreatingNew] = useState(false);
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)) });
}
// 只允许选分析完(analyze_status=done)的标杆——未分析的无 features 注入无意义
const usableBenchmarks = benchmarks.filter((b) => b.analyze_status === 'done');
// 只允许选真实分析完成的标杆fallback/failed 不注入,避免静默污染生成质量。
const usableBenchmarks = benchmarks.filter((b) => b.analyze_status === 'done' && b.analysis_source !== 'fallback');
function toggleBenchmark(id: number) {
const cur = form.benchmark_ids || [];
onFormChange({
@@ -38,30 +46,78 @@ export function NewTaskForm({
});
}
// 产品参考图状态image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问
const rawPath = selectedProduct?.image_path || '';
const hasProductImage = !!rawPath;
const imgSrc = rawPath
? '/uploads/' + rawPath.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')
: '';
// 产品参考图:优先用多图数组 images每张带 scene 角色),向后兼容单 image_path
const SCENE_LABEL: Record<string, string> = {
primary: '主图/白底', scene: '使用场景', texture: '质地特写',
ingredient: '成分/包装', model: '上脸/使用中',
};
const toSrc = (path: string) =>
'/uploads/' + path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '');
const productImages = selectedProduct?.images ?? [];
// 多图为空但有旧单图字段时,兜底成一张主图,避免老产品显示不出来
const refImages = productImages.length > 0
? productImages
: (selectedProduct?.image_path
? [{ id: -1, path: selectedProduct.image_path, scene: 'primary', is_primary: true, sort_order: 0 }]
: []);
const hasProductImage = refImages.length > 0;
// 禁降级:产品入镜但无图 → 主轨按钮禁用
const blockedByNoImage = form.need_product_image && !hasProductImage;
return (
<form className="space-y-6 max-w-xl" noValidate>
<div className="space-y-6 max-w-xl">{/* 外层用 div 不用 form内含 ProductEditPanel 的 formform 嵌 form 非法会 hydration 报错 */}
{/* 选产品 */}
<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 className="flex items-center justify-between mb-2">
<label htmlFor="product-select" className="block text-sm font-medium text-text-primary">
</label>
{!creatingNew && (
<div className="flex items-center gap-3">
{selectedProduct && !editingProduct && (
<button type="button" onClick={() => setEditingProduct(true)}
className="text-xs text-text-secondary hover:text-brand-orange font-medium">
</button>
)}
<button type="button" onClick={() => { setCreatingNew(true); setEditingProduct(false); }}
className="text-xs text-brand-orange hover:text-orange-600 font-medium">
+
</button>
</div>
)}
</div>
{creatingNew ? (
<QuickProductCreate
onCreated={(p) => { setCreatingNew(false); onProductCreated(p); }}
onCancel={() => setCreatingNew(false)}
/>
) : products.length === 0 ? (
<div className="text-xs text-text-tertiary bg-surface-secondary rounded px-3 py-2">
+
</div>
) : (
<>
<select id="product-select"
value={selectedProduct?.id ?? ''}
onChange={(e) => { setEditingProduct(false); 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>
{editingProduct && selectedProduct && (
<div className="mt-3">
<ProductEditPanel
product={selectedProduct}
onSaved={(p) => { onProductCreated(p); setEditingProduct(false); }}
onClose={() => setEditingProduct(false)}
/>
</div>
)}
</>
)}
</div>
{/* 产品入镜开关 + 参考图状态 */}
@@ -75,13 +131,30 @@ export function NewTaskForm({
</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 className="space-y-2">
<div className="flex items-center gap-2 text-xs text-status-success">
<span> {refImages.length} </span>
</div>
<div className="grid grid-cols-4 gap-2">
{refImages.map((im) => (
<div key={im.id} className="space-y-1">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={toSrc(im.path)} alt={`${SCENE_LABEL[im.scene] ?? im.scene}`}
className="w-full aspect-square rounded object-cover border border-border-default" />
<p className="text-[10px] text-center text-text-secondary truncate">
{im.is_primary && <span className="text-brand-orange"></span>}
{SCENE_LABEL[im.scene] ?? im.scene}
</p>
</div>
))}
</div>
<p className="text-[11px] text-text-tertiary">
//
</p>
</div>
) : (
<div className="text-xs text-status-error bg-red-50 rounded px-3 py-2" role="alert">
-
</div>
)
)}
@@ -127,12 +200,19 @@ export function NewTaskForm({
</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)} />
{/* 数量设定(不写死)。套数固定3套(痛点/场景/成分背书三正交叙事,倩倩姐拍板不可调),
用户只调每套张数;总图数 = 3套 × 每套张数 */}
<div className="space-y-3">
<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>
<p className="text-xs text-text-tertiary bg-surface-secondary rounded px-3 py-2">
<span className="font-medium text-text-secondary">3 </span> / /
{form.image_count} <span className="font-medium text-brand-orange">{form.image_count * 3} </span>
</p>
</div>
{error && (
@@ -143,7 +223,7 @@ export function NewTaskForm({
{/* 双轨入口 */}
<div className="flex gap-3">
<button type="submit" disabled={submitting || blockedByNoImage} onClick={onSubmitAi}
<button type="button" 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">
@@ -155,6 +235,6 @@ export function NewTaskForm({
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
/**
* ProductEditPanel — 选中已有产品后就地编辑(开任务页「编辑配置」入口)
* 倩倩姐2026-06-26编辑配置与产品档案编辑统一走 ProductFormFull全字段卖点/风格/人群/品牌词)。
* 本面板 = 完整字段表单 + 产品图管理(换图加图),两块各自实时落库。
*/
import { useState } from 'react';
import { Product } from '@/types/index';
import { ProductFormFull } from '@/components/config/ProductFormFull';
import { ProductImageManager } from '@/components/config/ProductImageManager';
export function ProductEditPanel({
product,
onSaved,
onClose,
}: {
product: Product;
onSaved: (p: Product) => void;
onClose: () => void;
}) {
// 图片改动实时反映PUT 不含 images合并保留图片态后回交父组件
const [cur, setCur] = useState<Product>(product);
return (
<div className="space-y-3">
<ProductFormFull
product={cur}
onSaved={(p) => {
const merged = { ...p, images: cur.images, image_path: cur.image_path };
setCur(merged);
onSaved(merged);
}}
onCancel={onClose}
/>
<div className="border border-border-default rounded-lg p-4 bg-surface-primary">
<p className="text-xs text-text-secondary mb-2"></p>
<ProductImageManager product={cur} onChanged={(u) => { setCur(u); onSaved(u); }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
/**
* QuickProductCreate — 开任务页内联快速建品(#1 选产品支持文字输入+就地上传)
* 不跳配置页:输入产品名/品牌词 → 建产品 → 复用 ProductImageManager 就地传图。
* 建好后通过 onCreated 把新产品回交父组件,自动选中。
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { Product } from '@/types/dto';
import { ProductImageManager } from '@/components/config/ProductImageManager';
export function QuickProductCreate({
onCreated,
onCancel,
}: {
onCreated: (p: Product) => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
const [brandKeyword, setBrandKeyword] = useState('');
const [creating, setCreating] = useState(false);
const [error, setError] = useState('');
// 已建出的产品(建完才能传图,因 upload-image 需 product_id
const [created, setCreated] = useState<Product | null>(null);
async function handleCreate() {
if (!name.trim()) { setError('请填写产品名'); return; }
setError('');
setCreating(true);
try {
const p = await api.post<Product>('/api/v1/products', {
name: name.trim(),
brand_keyword: brandKeyword.trim() || undefined,
source: 'custom',
});
setCreated(p);
} catch {
setError('建产品失败,请重试');
} finally {
setCreating(false);
}
}
return (
<div className="border border-brand-orange/30 rounded-lg p-4 space-y-3 bg-brand-cream/40">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-text-primary"></span>
<button type="button" onClick={onCancel}
className="text-xs text-text-tertiary hover:text-text-secondary"></button>
</div>
{!created ? (
<>
<input type="text" value={name} onChange={(e) => setName(e.target.value)}
placeholder="产品名,如:倍分子素颜霜" maxLength={64}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
aria-label="产品名" />
<input type="text" value={brandKeyword} onChange={(e) => setBrandKeyword(e.target.value)}
placeholder="品牌词(可选),如:倍分子"
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
aria-label="品牌词" />
{error && <p className="text-xs text-red-500" role="alert">{error}</p>}
<button type="button" onClick={handleCreate} disabled={creating}
className="w-full bg-brand-orange text-white rounded-lg py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
{creating ? '创建中…' : '创建并上传产品图'}
</button>
</>
) : (
<div className="space-y-3">
<p className="text-xs text-status-success"> {created.name}</p>
<ProductImageManager product={created}
onChanged={(updated) => setCreated(updated)} />
{(created.images?.length ?? 0) === 0 ? (
<p className="text-xs text-status-error bg-red-50 rounded px-3 py-2" role="alert">
</p>
) : (
<button type="button" onClick={() => onCreated(created)}
className="w-full bg-brand-orange text-white rounded-lg py-2 text-sm font-medium hover:bg-orange-600">
</button>
)}
<button type="button" onClick={() => onCreated(created)}
className="w-full text-xs text-text-tertiary hover:text-text-secondary py-1">
使
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
/**
* StageProgress — 文案/图片生成阶段进度A2
* scope=text6 步文案阶段scope=image图片阶段显示全局 4 步
* 用 StageLoadingPanel 统一渲染,真实 SSE 数据驱动
*/
import { useGenerationStore, GenStage } from '@/stores/generationStore';
import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
// ─── 文案 6 步 ─────────────────────────────────────────────────────────────
const TEXT_STAGES = [
{ label: '整理产品资料' },
{ label: '规划不同选题角度' },
{ label: '生成候选文案池' },
{ label: '90 分评分与去重' },
{ label: '必要时自动优化' },
{ label: '整理可发布稿' },
];
// ─── 图片整体 4 步scope=image 全局进度,不替代 ImageProgressHeader────
const IMAGE_OVERVIEW_STAGES = [
{ label: '分析竞品标杆' },
{ label: '生成候选文案' },
{ label: '配图生成中' },
{ label: '完成' },
];
const ORDER: GenStage[] = ['idle', 'analyzing', 'text', 'image', 'done'];
/** 文案阶段stage=analyzing/text→ TEXT_STAGES 的激活索引 */
function textActiveIdx(stage: GenStage, textDone: number, textTotal: number): number {
if (stage === 'idle') return -1;
if (stage === 'analyzing') return 0; // 整理产品资料
if (stage === 'text') {
if (textDone === 0) return 1; // 规划角度
if (textDone < textTotal) return 2; // 生成中
return 3; // 评分去重
}
return TEXT_STAGES.length; // 后续阶段=全完
}
/** 图片概览阶段 → IMAGE_OVERVIEW_STAGES 的激活索引 */
function overviewActiveIdx(stage: GenStage): number {
const idx = ORDER.indexOf(stage);
if (idx <= 0) return -1;
return Math.min(idx - 1, IMAGE_OVERVIEW_STAGES.length - 1);
}
export function StageProgress({ scope }: { scope: 'text' | 'image' }) {
const { stage, textDone, textTotal, startedAt } = useGenerationStore();
if (stage === 'idle') return null;
if (stage === 'failed') return null;
if (scope === 'text') {
const activeIdx = textActiveIdx(stage, textDone, textTotal);
const allDone = stage !== 'analyzing' && stage !== 'text';
return (
<StageLoadingPanel
title="生成文案中"
stages={TEXT_STAGES}
activeIdx={allDone ? TEXT_STAGES.length : activeIdx}
targetSec={58}
startedAt={startedAt}
className="mb-4"
/>
);
}
// scope=image展示整体 4 步概览进度条
const activeIdx = overviewActiveIdx(stage);
const allDone = stage === 'done';
return (
<StageLoadingPanel
title="整体生成进度"
stages={IMAGE_OVERVIEW_STAGES}
activeIdx={allDone ? IMAGE_OVERVIEW_STAGES.length : activeIdx}
targetSec={120}
startedAt={startedAt}
className="mb-4"
/>
);
}

View File

@@ -3,6 +3,7 @@
import { useState } from 'react';
import { TextCandidate } from '@/types/index';
import { ScoreDimBars } from './ScoreDimBars';
import { useAuthStore } from '@/stores/authStore';
import { clsx } from 'clsx' ;
interface TextCandidateCardProps {
@@ -10,6 +11,7 @@ interface TextCandidateCardProps {
selected: boolean;
onToggle: (id: number) => void;
onSaveEdit?: (id: number, content: string) => Promise<void>;
onDelete?: (id: number) => Promise<void>;
}
const BANNED_STATUS_STYLES = {
@@ -25,12 +27,16 @@ const VERDICT_STYLES: Record<string, string> = {
'不合格':'text-status-error',
};
export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }: TextCandidateCardProps) {
export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit, onDelete }: TextCandidateCardProps) {
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(candidate.content);
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
// 删除权限仅管理员倩倩姐2026-06-30拍板非管理员不渲染删除入口
const isAdmin = useAuthStore((s) => s.role) === 'admin';
async function handleSave(e: React.MouseEvent) {
e.stopPropagation();
@@ -46,6 +52,18 @@ export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }:
}
}
async function handleDelete(e: React.MouseEvent) {
e.stopPropagation();
if (!onDelete || deleting) return;
setDeleting(true);
try {
await onDelete(candidate.candidate_id);
} finally {
setDeleting(false);
setConfirmDelete(false);
}
}
return (
<article
onClick={() => onToggle(candidate.candidate_id)}
@@ -65,6 +83,41 @@ export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }:
</span>
)}
{/* 删除按钮(悬停出现,仅管理员) */}
{onDelete && isAdmin && !confirmDelete && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
aria-label="删除此文案"
className={clsx(
'absolute top-2 text-xs text-text-tertiary hover:text-status-error transition-colors',
candidate.source === 'import' ? 'right-14' : 'right-2',
)}
>
</button>
)}
{/* 删除确认 */}
{confirmDelete && (
<div
className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-xl bg-surface-primary/95 gap-2"
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs text-text-secondary"></p>
<div className="flex gap-2">
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(false); }}
className="text-xs text-text-secondary border border-border-default rounded-md px-2.5 py-1 hover:bg-surface-tertiary">
</button>
<button type="button" onClick={handleDelete} disabled={deleting}
className="text-xs text-white bg-status-error rounded-md px-2.5 py-1 hover:opacity-90 disabled:opacity-50">
{deleting ? '删除中…' : '确认删除'}
</button>
</div>
</div>
)}
{/* 选中勾 */}
{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"

View File

@@ -0,0 +1,56 @@
'use client';
// TextLightbox — 点击文案看全文已通过任务只读查看用ESC/点遮罩关闭
import { useEffect } from 'react';
interface TextLightboxProps {
text: string | null;
label?: string;
score?: number;
onClose: () => void;
}
export function TextLightbox({ text, label, score, onClose }: TextLightboxProps) {
useEffect(() => {
if (!text) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [text, onClose]);
if (!text) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="文案全文"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
>
<div
className="relative max-h-[85vh] w-full max-w-2xl overflow-auto rounded-xl bg-white p-6"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
aria-label="关闭全文"
className="absolute top-3 right-3 h-8 w-8 rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200"
>
</button>
{label && <span className="text-xs font-medium text-brand-orange">{label}</span>}
<p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-text-primary">{text}</p>
{typeof score === 'number' && (
<span className="mt-3 block text-xs text-text-tertiary"> {score}</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
/**
* TextOutcomeBanner — 文案产出结果提示B1
* task_done 后,若产出不足/评分不可用/全不合格,显式告诉用户真实原因,
* 不再让页面停在骨架屏装"生成中",也不假装"完成"绿勾。
* 倩倩姐2026-06-26评分通道挂了要明确报错不静默糊弄。
*/
import { useGenerationStore } from '@/stores/generationStore';
export function TextOutcomeBanner() {
const { stage, textOutcome } = useGenerationStore();
// 仅在任务跑完(task_done→done)后、且产出有异常时提示;正常足量不打扰
if (stage !== 'done' || !textOutcome || textOutcome.reason === null) return null;
const { saved, target, reason } = textOutcome;
const MAP: Record<string, { tone: 'error' | 'warn'; title: string; desc: string }> = {
scoring_unavailable: {
tone: 'error',
title: '评分服务暂不可用,文案未能定稿',
desc: '评分通道(主+备)暂时连不上,本次没有产出合格文案——这不是文案质量问题。请确认中转站额度后,点下方「重试」重新生成。',
},
generation_failed: {
tone: 'error',
title: '文案生成失败',
desc: '生成通道暂时不可用,本次未产出文案。请稍后点「重试」,或确认中转站 Key/额度是否正常。',
},
quality_filtered: {
tone: 'warn',
title: '本批文案未达发布标准',
desc: `已生成的文案均未过 80 分合格线,系统正在后台补充更高质量的版本,请稍候刷新;若长时间没有,可点「重试」。`,
},
replenishing: {
tone: 'warn',
title: `已出 ${saved}/${target} 条,正在后台补充`,
desc: '合格文案还差几条,系统正在后台继续生成,达到目标条数后会自动补上,无需离开页面。',
},
};
const cfg = MAP[reason];
if (!cfg) return null;
const styles = cfg.tone === 'error'
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-amber-50 border-amber-200 text-amber-700';
return (
<div className={`rounded-xl border px-4 py-3 mb-4 ${styles}`} role="alert">
<p className="font-semibold text-sm">{cfg.title}</p>
<p className="text-xs mt-1 leading-relaxed">{cfg.desc}</p>
</div>
);
}

View File

@@ -15,10 +15,11 @@ interface TextStrategyGroupProps {
selectedTextIds: number[];
onToggle: (id: number) => void;
onSaveEdit?: (id: number, content: string) => Promise<void>;
onDelete?: (id: number) => Promise<void>;
}
export function TextStrategyGroup({
strategy, candidates, selectedTextIds, onToggle, onSaveEdit,
strategy, candidates, selectedTextIds, onToggle, onSaveEdit, onDelete,
}: TextStrategyGroupProps) {
const label = strategy === '_' ? '未分套' : (STRATEGY_LABEL[strategy] || `${strategy}`);
return (
@@ -37,6 +38,7 @@ export function TextStrategyGroup({
selected={selectedTextIds.includes(c.candidate_id)}
onToggle={onToggle}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
/>
))}
</div>