上线版: 产品表单统一+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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
158
frontend/src/components/common/StageLoadingPanel.tsx
Normal file
158
frontend/src/components/common/StageLoadingPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">备用通道 codeproxy(apiports 繁忙时自动顶上,选填)</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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
88
frontend/src/components/config/CreateUserModal.tsx
Normal file
88
frontend/src/components/config/CreateUserModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/config/ProductFormFull.tsx
Normal file
127
frontend/src/components/config/ProductFormFull.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
/**
|
||||
* ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件
|
||||
* ProductCard + ProductsSkeleton — 产品档案子组件(编辑/新建统一走 ProductFormFull)
|
||||
* 产品多图管理拆到 ProductImageManager(R5)。
|
||||
*/
|
||||
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="加载产品档案中">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
168
frontend/src/components/config/UserManageTab.tsx
Normal file
168
frontend/src/components/config/UserManageTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 参考强度 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
91
frontend/src/components/fission/FissionSourceCard.tsx
Normal file
91
frontend/src/components/fission/FissionSourceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
frontend/src/components/tasks/DeleteTaskButton.tsx
Normal file
66
frontend/src/components/tasks/DeleteTaskButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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 的 form,form 嵌 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>
|
||||
);
|
||||
}
|
||||
|
||||
41
frontend/src/components/tasks/ProductEditPanel.tsx
Normal file
41
frontend/src/components/tasks/ProductEditPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/tasks/QuickProductCreate.tsx
Normal file
91
frontend/src/components/tasks/QuickProductCreate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/tasks/StageProgress.tsx
Normal file
83
frontend/src/components/tasks/StageProgress.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
/**
|
||||
* StageProgress — 文案/图片生成阶段进度(A2)
|
||||
* scope=text:6 步文案阶段;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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
56
frontend/src/components/tasks/TextLightbox.tsx
Normal file
56
frontend/src/components/tasks/TextLightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/tasks/TextOutcomeBanner.tsx
Normal file
54
frontend/src/components/tasks/TextOutcomeBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user