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

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

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

View File

@@ -5,6 +5,7 @@
* 复用 history 的状态色 + Link 跳转风格,保持视觉一致不割裂。
*/
import Link from 'next/link';
import { useState } from 'react';
import { clsx } from 'clsx';
import { TaskListItem } from '@/types';
@@ -18,8 +19,11 @@ export interface ColumnDef {
export const BOARD_COLUMNS: ColumnDef[] = [
{ key: 'generating', label: '生成中',
statuses: ['pending', 'generating', 'pending_selection'],
statuses: ['pending', 'generating'],
accent: 'text-blue-600 bg-blue-50' },
{ key: 'pending_selection', label: '待提交',
statuses: ['pending_selection'],
accent: 'text-indigo-600 bg-indigo-50' },
{ key: 'review', label: '待审核',
statuses: ['pending_review'], accent: 'text-amber-600 bg-amber-50' },
{ key: 'approved', label: '已通过',
@@ -28,42 +32,78 @@ export const BOARD_COLUMNS: ColumnDef[] = [
statuses: ['rejected'], accent: 'text-red-500 bg-red-50' },
];
// 卡片点击目标:生成中/待审核进 confirm 看文案,其余进 images 排图/查看
// 卡片点击目标:除「待提交(pending_selection)」进 images 排图外,其余都进 confirm
// (生成中/待审核=看文案进度,已通过/已打回=只读查看文案+图)
function cardHref(task: TaskListItem): string {
if (['pending', 'generating', 'pending_selection', 'pending_review'].includes(task.status)) {
return `/tasks/${task.id}/confirm`;
if (task.status === 'pending_selection') {
return `/tasks/${task.id}/images`;
}
return `/tasks/${task.id}/images`;
return `/tasks/${task.id}/confirm`;
}
// --- TaskCard --- 单个任务卡,点击进详情
export function TaskCard({ task }: { task: TaskListItem }) {
// onDelete 可选:仅管理员且非「待审核」列传入 → 卡右上角出删除按钮(先确认再删)
export function TaskCard({ task, onDelete }: { task: TaskListItem; onDelete?: (id: number) => void }) {
const [confirming, setConfirming] = useState(false);
return (
<Link
href={cardHref(task)}
aria-label={`任务 ${task.theme}`}
className="block rounded-lg border border-border-default bg-white p-3 hover:border-brand-orange hover:shadow-sm transition-all"
>
<p className="text-sm font-medium text-text-primary line-clamp-2">{task.theme}</p>
<p className="mt-1 text-xs text-text-secondary">{task.product_name ?? '—'}</p>
<div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary">
<span>{task.text_count} </span>
<span>·</span>
<span>{task.image_count} </span>
<span className="ml-auto">{new Date(task.created_at).toLocaleDateString('zh-CN')}</span>
</div>
{/* 被打回任务:列内直接显示原因摘要,运营一眼看到为何被打回 */}
{task.status === 'rejected' && task.reject_reason && (
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-xs text-red-600 line-clamp-2">
{task.reject_reason}
</p>
<div className="relative">
<Link
href={cardHref(task)}
aria-label={`任务 ${task.theme}`}
className="block rounded-lg border border-border-default bg-white p-3 hover:border-brand-orange hover:shadow-sm transition-all"
>
<p className="text-sm font-medium text-text-primary line-clamp-2 pr-6">{task.theme}</p>
<p className="mt-1 text-xs text-text-secondary">{task.product_name ?? '—'}</p>
<div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary">
<span>{task.text_count} </span>
<span>·</span>
<span>{task.image_count} </span>
<span className="ml-auto">{new Date(task.created_at).toLocaleDateString('zh-CN')}</span>
</div>
{/* 被打回任务:列内直接显示原因摘要,运营一眼看到为何被打回 */}
{task.status === 'rejected' && task.reject_reason && (
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-xs text-red-600 line-clamp-2">
{task.reject_reason}
</p>
)}
</Link>
{/* 删除按钮(管理员,非待审核列):浮在 Link 外层,点击不触发跳转 */}
{onDelete && !confirming && (
<button
onClick={() => setConfirming(true)}
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded text-text-tertiary hover:bg-red-50 hover:text-red-500"
aria-label="删除任务"
title="删除任务"
>
</button>
)}
</Link>
{onDelete && confirming && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-white/95 p-2 text-center">
<p className="text-xs text-text-primary"></p>
<div className="flex gap-2">
<button
onClick={() => setConfirming(false)}
className="rounded border border-border-default px-2 py-0.5 text-xs text-text-secondary hover:bg-surface-secondary"
>
</button>
<button
onClick={() => { onDelete(task.id); }}
className="rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600"
>
</button>
</div>
</div>
)}
</div>
);
}
// --- BoardColumn --- 状态列:列头 + 计数 + 卡片堆叠
export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListItem[] }) {
// onDelete 可选传入则该列卡片显删除按钮page 仅对管理员+非待审核列传)
export function BoardColumn({ col, tasks, onDelete }: { col: ColumnDef; tasks: TaskListItem[]; onDelete?: (id: number) => void }) {
return (
<div className="flex flex-col min-w-[240px] flex-1 rounded-xl bg-surface-secondary p-3">
<div className="mb-3 flex items-center gap-2">
@@ -76,7 +116,7 @@ export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListIte
{tasks.length === 0 ? (
<p className="py-8 text-center text-xs text-text-tertiary"></p>
) : (
tasks.map((t) => <TaskCard key={t.id} task={t} />)
tasks.map((t) => <TaskCard key={t.id} task={t} onDelete={onDelete} />)
)}
</div>
</div>

View File

@@ -8,6 +8,7 @@
*/
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors';
import { BOARD_COLUMNS, BoardColumn, BoardSkeleton } from './components';
@@ -20,6 +21,8 @@ export default function BoardPage() {
const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [productId, setProductId] = useState<number | 'all'>('all'); // 顶部产品筛选
const isAdmin = useAuthStore((s) => s.role) === 'admin'; // 删除按钮仅管理员可见
const fetchTasks = useCallback(async () => {
setLoading(true);
@@ -39,10 +42,27 @@ export default function BoardPage() {
useEffect(() => { fetchTasks(); }, [fetchTasks]);
// 按列分组:每列收自己 statuses 命中的任务
// 删除整个任务(仅管理员):后端智能删(有成品→归档保留,残次→物理删),成功后本地移除卡片
const handleDelete = useCallback(async (id: number) => {
try {
await api.delete(`/api/v1/tasks/${id}`);
setTasks((prev) => prev.filter((t) => t.id !== id));
} catch (e: unknown) {
const ae = e as { code?: number };
setError(getErrorAction(ae.code ?? 50001).message);
}
}, []);
// 任务里出现过的产品去重,喂给顶部下拉(数据已带 product_id/product_name不改后端
const products = Array.from(
new Map(tasks.map((t) => [t.product_id, t.product_name ?? `产品#${t.product_id}`])).entries(),
).map(([id, name]) => ({ id, name }));
// 先按选中产品过滤,再按列分组
const visibleTasks = productId === 'all' ? tasks : tasks.filter((t) => t.product_id === productId);
const byColumn = BOARD_COLUMNS.map((col) => ({
col,
items: tasks.filter((t) => col.statuses.includes(t.status)),
items: visibleTasks.filter((t) => col.statuses.includes(t.status)),
}));
return (
@@ -58,6 +78,21 @@ export default function BoardPage() {
</button>
</div>
<div className="flex items-center gap-2">
<label htmlFor="board-product" className="text-sm text-text-secondary"></label>
<select
id="board-product"
value={productId}
onChange={(e) => setProductId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-primary focus:outline-none focus:border-brand-orange"
>
<option value="all"></option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
@@ -70,7 +105,13 @@ export default function BoardPage() {
) : (
<div className="flex gap-4 flex-1 overflow-x-auto">
{byColumn.map(({ col, items }) => (
<BoardColumn key={col.key} col={col} tasks={items} />
<BoardColumn
key={col.key}
col={col}
tasks={items}
/* 删除按钮:仅管理员,且「待审核」列不放(审核中不允许删) */
onDelete={isAdmin && col.key !== 'review' ? handleDelete : undefined}
/>
))}
</div>
)}

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
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';
export default function ChangePasswordPage() {
const router = useRouter();
const { user, fetchMe } = useAuthStore();
const mustChange = !!user?.must_change_password;
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (newPassword.length < 8) {
setError('新密码至少 8 位');
return;
}
if (newPassword !== confirmPassword) {
setError('两次输入的新密码不一致');
return;
}
setSaving(true);
setError('');
try {
await api.post('/api/v1/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
await fetchMe();
if (mustChange) {
router.replace(user?.role === 'admin' ? '/config' : user?.role === 'supervisor' ? '/review' : '/tasks/new');
} else {
router.back();
}
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || '修改密码失败');
} finally {
setSaving(false);
}
}
return (
<div className="flex items-center justify-center min-h-[calc(100vh-56px)] bg-brand-cream">
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8">
<h1 className="text-xl font-bold text-text-primary mb-2">
{mustChange ? '首次登录请修改密码' : '修改密码'}
</h1>
<p className="text-sm text-text-secondary mb-6">
{mustChange
? '为了保护客户数据,初始密码只能用于首次登录。'
: '建议定期更换密码,新密码至少 8 位。'}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="current-password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="current-password" type="password" autoComplete="current-password" value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label htmlFor="new-password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="new-password" type="password" autoComplete="new-password" value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} className={INPUT_CLS} />
</div>
{error && <div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg">{error}</div>}
<button type="submit" disabled={saving}
className="w-full bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
{saving ? '保存中…' : '修改密码'}
</button>
{!mustChange && (
<button type="button" onClick={() => router.back()}
className="w-full text-text-secondary rounded-lg py-2 text-sm hover:text-text-primary transition-colors">
</button>
)}
</form>
</div>
</div>
);
}

View File

@@ -7,12 +7,14 @@
import { useState } from 'react';
import { BenchmarksTab } from '@/components/config/BenchmarksTab';
import { BannedWordsTab } from '@/components/config/BannedWordsTab';
import { UserManageTab } from '@/components/config/UserManageTab';
type TabKey = 'benchmarks' | 'banned_words';
type TabKey = 'benchmarks' | 'banned_words' | 'users';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'benchmarks', label: '标杆笔记' },
{ key: 'banned_words', label: '违禁词库' },
{ key: 'users', label: '账号管理' },
];
export default function ConfigPage() {
@@ -46,6 +48,7 @@ export default function ConfigPage() {
<div id={`panel-${activeTab}`} role="tabpanel">
{activeTab === 'benchmarks' && <BenchmarksTab />}
{activeTab === 'banned_words' && <BannedWordsTab />}
{activeTab === 'users' && <UserManageTab />}
</div>
</div>
);

View File

@@ -28,8 +28,9 @@ export default function DashboardPage() {
<DashCard href="/review" icon="✅" label="审核台" desc="查看待审笔记,通过或打回" />
)}
{role === 'admin' && (
<DashCard href="/config" icon="⚙️" label="配置中心" desc="管理产品、标杆笔记、违禁词和 API Key" />
<DashCard href="/config" icon="⚙️" label="系统管理" desc="标杆笔记、违禁词库、账号管理" />
)}
<DashCard href="/settings" icon="🔧" label="工作配置" desc="产品档案、API Key" />
<DashCard href="/history" icon="🗂️" label="历史归档" desc="查看历史任务和成品库" />
</div>
</div>

View File

@@ -47,6 +47,7 @@ function DownloadButton({ taskId }: { taskId: number }) {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
await api.post(`/api/v1/delivery-packages/${id}/mark-downloaded`);
setState('done');
return;
}
@@ -55,7 +56,11 @@ function DownloadButton({ taskId }: { taskId: number }) {
} catch { setState('error'); }
}
if (state === 'done') return <span className="text-xs text-green-600"></span>;
if (state === 'done') return (
<span className="text-xs text-green-600" title="文件已保存到浏览器默认下载目录(通常是「下载」文件夹)">
</span>
);
if (state === 'error') return <span className="text-xs text-red-500"></span>;
return (
<button

View File

@@ -5,6 +5,7 @@
* 数据GET /api/v1/tasks?status=approved,archived,rejected
*/
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { api } from '@/lib/api';
import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors';
@@ -19,6 +20,8 @@ const STATUS_LABELS: Record<string, string> = {
const PAGE_SIZE = 20;
export default function HistoryPage() {
const searchParams = useSearchParams();
const isImageView = searchParams.get('view') === 'images';
const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@@ -58,10 +61,18 @@ export default function HistoryPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-text-primary"></h1>
<h1 className="text-xl font-semibold text-text-primary">
{isImageView ? '图片排版' : '历史归档'}
</h1>
<span className="text-sm text-text-tertiary"> {total} </span>
</div>
{isImageView && (
<p className="text-sm text-text-secondary -mt-2">
</p>
)}
{/* 状态筛选 */}
<div className="flex gap-2" role="group" aria-label="状态筛选">
{[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({

View File

@@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
import { Sidebar } from '@/components/layout/Sidebar';
export const metadata: Metadata = {
title: '龙石小红书内容生产平台',
title: 'Clover小红书内容生产平台',
description: '小红书内容生产工具',
};
@@ -21,7 +21,7 @@ export default function RootLayout({
{/* 顶部 Header */}
<header className="h-14 bg-brand-orange flex items-center px-6 justify-between flex-shrink-0">
<h1 className="text-white font-bold text-base">
Clover小红书内容生产平
</h1>
<UserBadge />
</header>

View File

@@ -1,6 +1,6 @@
'use client';
// /login — 登录页按角色跳转首页admin→/config, supervisor→/review, operator→/tasks/new
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
import { api } from '@/lib/api';
@@ -15,6 +15,14 @@ export default function LoginPage() {
const [form, setForm] = useState<LoginRequest>({ username: '', password: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showPwd, setShowPwd] = useState(false);
// 被 token 失效踢回登录页时给出明确提示api.ts 跳转会带 ?reason=expired
const [notice, setNotice] = useState('');
useEffect(() => {
if (typeof window === 'undefined') return;
const reason = new URLSearchParams(window.location.search).get('reason');
if (reason === 'expired') setNotice('登录状态已过期,请重新登录后再操作。');
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -27,6 +35,10 @@ export default function LoginPage() {
try {
const data = await api.post<LoginResponse>('/api/v1/auth/login', form);
login(data.token, data.user);
if (data.user.must_change_password) {
router.replace('/change-password');
return;
}
// 按角色跳对应首页
const dest =
data.user.role === 'admin'
@@ -49,11 +61,16 @@ export default function LoginPage() {
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8">
<div className="text-center mb-8">
<div className="text-4xl mb-2" aria-hidden="true">🌿</div>
<h1 className="text-xl font-bold text-text-primary"></h1>
<h1 className="text-xl font-bold text-text-primary">Clover内容生产平</h1>
<p className="text-text-secondary text-sm mt-1"></p>
</div>
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
{notice && (
<div role="status" className="text-sm bg-amber-50 text-amber-700 border border-amber-200 px-3 py-2 rounded-lg">
{notice}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="username" type="text" autoComplete="username" value={form.username}
@@ -63,9 +80,16 @@ export default function LoginPage() {
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="password" type="password" autoComplete="current-password" value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className={INPUT_CLS} aria-required="true" />
<div className="relative">
<input id="password" type={showPwd ? 'text' : 'password'} autoComplete="current-password" value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className={INPUT_CLS + ' pr-10'} aria-required="true" />
<button type="button" onClick={() => setShowPwd((v) => !v)}
aria-label={showPwd ? '隐藏密码' : '显示密码'}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary hover:text-text-primary text-base p-1">
{showPwd ? '👁️' : '🙈'}
</button>
</div>
</div>
{error && (

View File

@@ -5,7 +5,7 @@
* 🔴 红线key 各人自录自用各算各的;只录不显余额
* 拆分自原 /configB方案admin 专属的标杆/违禁词留在 /config「系统管理」
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ProductsTab } from '@/components/config/ProductsTab';
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
@@ -18,6 +18,12 @@ const TABS: { key: TabKey; label: string }[] = [
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<TabKey>('api_keys');
// 支持 ?tab=products 直达(开任务页「编辑配置」跳来)。
// 客户端挂载后再读 URL避免 SSR(无 window) 与客户端初值不一致引发 hydration mismatch。
useEffect(() => {
const t = new URLSearchParams(window.location.search).get('tab');
if (t === 'products') setActiveTab('products');
}, []);
return (
<div className="p-6 max-w-5xl">

View File

@@ -3,21 +3,82 @@
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
import { TextLightbox } from '@/components/tasks/TextLightbox';
import { useTaskStore } from '@/stores/taskStore';
import { useGenerationStore } from '@/stores/generationStore';
import { api } from '@/lib/api';
import { useState } from 'react';
import { TextCandidate, ImageCandidate } from '@/types/index';
import { useState, useEffect } from 'react';
export default function ConfirmPage() {
const params = useParams<{ id: string }>();
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { selectedTextIds, selectedImageIds } = useTaskStore();
const { selectedTextIds, selectedImageIds, setSelections } = useTaskStore();
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
const { textCandidates, imageCandidates } = useGenerationStore();
const { textCandidates, imageCandidates, hydrateFromTask } = useGenerationStore();
const [submitting, setSubmitting] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [hydrating, setHydrating] = useState(false);
const [hydrateError, setHydrateError] = useState(false); // C1: 拉取失败时给重试入口
const [submitError, setSubmitError] = useState(''); // 提审被后端拒绝时显式提示,不再吞错
// 飞轮节点反馈:提交审核/打回后短暂绿芽提示
const [flywheelMsg, setFlywheelMsg] = useState<string | null>(null);
// 失败任务查看不能空白(#11):记录任务状态+失败原因failed 时显眼提示
const [taskMeta, setTaskMeta] = useState<{ status: string; failReason: string } | null>(null);
// 已通过/已打回 = 只读查看态倩倩姐2026-06-26不提交审核文案点看全文、图点放大
const isReadonly = taskMeta?.status === 'approved' || taskMeta?.status === 'rejected';
const [zoomImage, setZoomImage] = useState<ImageCandidate | null>(null);
const [zoomText, setZoomText] = useState<TextCandidate | null>(null);
// store 无持久化:从历史归档「查看」直达本页时 store 是空的 → 「暂无选中文案」。
// 挂载时拉 GET /tasks/{id}:候选为空则回填+据 is_selected 还原已选;
// 同时记录任务状态/失败原因,失败任务不再空白展示(#11)。
const loadTask = (id: number) => {
// 跨任务切换修复倩倩姐2026-06-26报bugSPA 路由切到不同任务卡片时,
// 上个任务数据仍在 store → storeEmpty=false → 旧逻辑直接 return 显示上一个任务,
// 手动刷新才好(刷新=重挂载清空 store。这里按 currentTaskId 判定是否换了任务,
// 换了就先清两个 store让本次走"空 store 全量回填"路径,根治"显示上个任务/暂无选中"。
const ts = useTaskStore.getState();
if (ts.currentTaskId !== id) {
useGenerationStore.getState().reset();
ts.reset();
ts.setCurrentTask(id, 'pending_selection');
}
const storeEmpty =
useGenerationStore.getState().textCandidates.length === 0 &&
useGenerationStore.getState().imageCandidates.length === 0;
if (storeEmpty) setHydrating(true);
setHydrateError(false);
api.get<{
status: string; reject_reason?: string | null;
text_candidates: TextCandidate[]; image_candidates: ImageCandidate[];
}>(`/api/v1/tasks/${id}`)
.then((data) => {
setTaskMeta({ status: data.status, failReason: data.reject_reason || '' });
if (!storeEmpty) return;
const texts = data.text_candidates || [];
const images = data.image_candidates || [];
hydrateFromTask({ textCandidates: texts, imageCandidates: images });
// hydrateFromTask 内已据 is_selected 写 taskStore但 storeEmpty 路径
// generationStore 可能比 taskStore setSelections 先跑,兜底再调一次
setSelections(
texts.filter((c) => c.is_selected).map((c) => c.candidate_id),
images.filter((c) => c.is_selected).map((c) => c.candidate_id),
);
})
.catch(() => { setHydrateError(true); }) // C1: 不再静默,给重试入口
.finally(() => setHydrating(false));
};
useEffect(() => {
if (!taskId) return;
loadTask(taskId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id));
const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
@@ -25,9 +86,17 @@ export default function ConfirmPage() {
async function handleSubmitReview() {
if (!taskId) return;
setSubmitting(true);
setSubmitError('');
try {
await api.post(`/api/v1/tasks/${taskId}/submit-review`);
router.push('/review');
setFlywheelMsg('🌿 已学习你的选择,下次更懂你');
setTimeout(() => {
setFlywheelMsg(null);
router.push('/review');
}, 2500);
} catch (e) {
// 后端校验失败(如未选满)以前被 finally 吞掉 → 用户看着像"点了没反应"
setSubmitError(e instanceof Error ? e.message : '提交审核失败,请重试');
} finally {
setSubmitting(false);
}
@@ -38,7 +107,11 @@ export default function ConfirmPage() {
setRegenerating(true);
try {
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
router.push(`/tasks/${taskId}/text`);
setFlywheelMsg('🌿 已记录你的顾虑,下次会避开');
setTimeout(() => {
setFlywheelMsg(null);
router.push(`/tasks/${taskId}/text`);
}, 2500);
} finally {
setRegenerating(false);
}
@@ -46,27 +119,66 @@ export default function ConfirmPage() {
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={4} />
{/* 已通过/已打回=只读查看不显示步骤进度条倩倩姐2026-06-26 */}
{!isReadonly && <ProgressBar currentStep={4} />}
<div className="p-6 flex-1 overflow-auto max-w-4xl">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-text-primary">
{isReadonly ? '查看笔记' : '确认预览'}
</h2>
{taskId && <DeleteTaskButton taskId={taskId} />}
</div>
{/* C1: 拉取失败给重新加载入口,避免永久灰死 */}
{hydrateError && (
<div className="mb-6 rounded-lg bg-yellow-50 border border-yellow-200 p-4" role="alert">
<p className="text-sm text-yellow-700"></p>
<button
onClick={() => taskId && loadTask(taskId)}
className="mt-2 text-sm bg-brand-orange text-white rounded-lg px-4 py-1.5 hover:bg-orange-600"
>
</button>
</div>
)}
{taskMeta?.status === 'failed' && (
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4" role="alert">
<p className="text-sm font-semibold text-red-600 mb-1"> </p>
<p className="text-sm text-red-600 whitespace-pre-wrap">
{taskMeta.failReason || '生成过程中出错,未产出可用内容。可点下方「重新生成」重试。'}
</p>
<button
onClick={handleRegenerate}
disabled={regenerating}
className="mt-3 text-sm bg-brand-orange text-white rounded-lg px-4 py-2 hover:bg-orange-600 disabled:opacity-50"
>
{regenerating ? '重新生成中…' : '重新生成'}
</button>
</div>
)}
<div className="grid grid-cols-2 gap-6">
{/* 已选文案 */}
<div>
<h3 className="text-sm font-semibold text-text-secondary mb-3"></h3>
{selectedTexts.length === 0 ? (
{hydrating ? (
<p className="text-text-tertiary text-sm"></p>
) : selectedTexts.length === 0 ? (
<p className="text-text-tertiary text-sm"></p>
) : (
<div className="space-y-3">
{selectedTexts.map((c) => (
<div key={c.candidate_id}
className="border border-border-default rounded-xl p-4 bg-surface-primary">
onClick={isReadonly ? () => setZoomText(c) : undefined}
className={`border border-border-default rounded-xl p-4 bg-surface-primary ${isReadonly ? 'cursor-pointer hover:border-brand-orange' : ''}`}>
<span className="text-xs text-brand-orange font-medium">{c.angle_label}</span>
<p className="text-sm text-text-primary mt-1 whitespace-pre-wrap line-clamp-6">
<p className={`text-sm text-text-primary mt-1 whitespace-pre-wrap ${isReadonly ? '' : 'line-clamp-6'}`}>
{c.content}
</p>
<span className="text-xs text-text-tertiary mt-1 block"> {c.score.total}</span>
<span className="text-xs text-text-tertiary mt-1 block">
{c.score.total}{isReadonly ? ' · 点击看全文' : ''}
</span>
</div>
))}
</div>
@@ -74,18 +186,55 @@ export default function ConfirmPage() {
</div>
{/* 已选图 */}
<ConfirmPreviewImages images={selectedImages} />
<ConfirmPreviewImages images={selectedImages} onZoom={isReadonly ? setZoomImage : undefined} />
</div>
{/* 操作栏 */}
<ConfirmActionBar
submitting={submitting}
regenerating={regenerating}
canSubmit={selectedTextIds.length > 0}
onSubmit={handleSubmitReview}
onRegenerate={handleRegenerate}
/>
{/* 提审被拒:显式红框,不再静默吞错 */}
{submitError && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3" role="alert">
<p className="text-sm text-red-600">{submitError}</p>
</div>
)}
{/* 飞轮节点反馈:提交成功后短暂绿芽提示 */}
{flywheelMsg && (
<div role="status" aria-live="polite"
className="mb-4 rounded-lg bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark">
{flywheelMsg}
</div>
)}
{/* 操作栏:只读态(已通过/已打回)不提交审核,只给只读说明;否则正常确认操作 */}
{isReadonly ? (
<div className="mt-6 rounded-lg bg-gray-50 border border-border-default px-4 py-3 text-sm text-text-secondary">
{taskMeta?.status === 'approved'
? '本笔记已通过审核,当前为查看模式。点击文案看全文、点击图片放大查看。'
: '本笔记已被打回,当前为查看模式。点击文案看全文、点击图片放大查看。'}
</div>
) : (
<ConfirmActionBar
submitting={submitting}
regenerating={regenerating}
hydrating={hydrating}
canSubmit={selectedTextIds.length > 0 && selectedImageIds.length > 0}
onSubmit={handleSubmitReview}
onRegenerate={handleRegenerate}
/>
)}
</div>
{/* 只读查看:文案全文 / 图片放大 */}
<TextLightbox
text={zoomText?.content ?? null}
label={zoomText?.angle_label}
score={zoomText?.score.total}
onClose={() => setZoomText(null)}
/>
<ImageLightbox
url={zoomImage?.url ?? null}
caption={zoomImage?.role}
onClose={() => setZoomImage(null)}
/>
</div>
);
}

View File

@@ -1,16 +1,18 @@
'use client';
// 屏4 挑图0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
import { ImageStrategyGroup, ROLE_LABEL } from '@/components/tasks/ImageStrategyGroup';
import { RegenControls, RegenTarget } from '@/components/tasks/RegenControls';
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { StageProgress } from '@/components/tasks/StageProgress';
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
import { useSse } from '@/hooks/useSse';
@@ -24,11 +26,14 @@ export default function ImageSelectionPage() {
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected, removeImageCandidate } = useGenerationStore();
const { selectedImageIds, toggleImageSelection } = useTaskStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId);
const [zoom, setZoom] = useState<ImageCandidate | null>(null);
const [regenTarget, setRegenTarget] = useState<RegenTarget | null>(null);
const [regenPending, setRegenPending] = useState<Record<string, { label: string; minId: number }>>({});
const [regenNotice, setRegenNotice] = useState<string | null>(null);
const [justSavedFlywheel, setJustSavedFlywheel] = useState(false);
const pendingCount = imageTotal - imageDone;
const allDone = imageTotal > 0 && pendingCount === 0;
@@ -45,6 +50,29 @@ export default function ImageSelectionPage() {
const groups: Array<['A' | 'B' | 'C', ImageCandidate[]]> = (['A', 'B', 'C'] as const).map(
(s) => [s, latestByRole(imageCandidates.filter((c) => c.strategy === s))]
);
const regeneratingKeys = useMemo(() => new Set(Object.keys(regenPending)), [regenPending]);
useEffect(() => {
setRegenPending((pending) => {
let changed = false;
const next = { ...pending };
for (const [key, meta] of Object.entries(pending)) {
const [strategy, role] = key.split(':');
const finished = imageCandidates.some((c) =>
c.strategy === strategy &&
(role === '*' || c.role === role) &&
c.candidate_id > meta.minId &&
c.is_regen
);
if (finished) {
delete next[key];
changed = true;
setRegenNotice(`${meta.label} 已重生完成,已展示最新图片`);
}
}
return changed ? next : pending;
});
}, [imageCandidates]);
async function handleRetry(_batchRole: string) {
// 一期 regenerate = 整体重生R2 已扩展按角色单批重生,见 handleRegen
@@ -55,10 +83,23 @@ export default function ImageSelectionPage() {
// R2 单张/单套重生 + 可选人工提示词
async function handleRegen(target: RegenTarget, customPrompt: string) {
if (!taskId) return;
const key = `${target.strategy}:${target.role ?? '*'}`;
if (regenPending[key]) return;
const minId = imageCandidates.reduce((max, c) => Math.max(max, c.candidate_id), 0);
setRegenPending((prev) => ({ ...prev, [key]: { label: target.label, minId } }));
setRegenNotice(`${target.label} 已提交重生,生成完成后会自动替换为最新图片`);
await api.post(`/api/v1/tasks/${taskId}/regenerate`, {
strategy: target.strategy,
role: target.role,
custom_prompt: customPrompt || undefined,
}).catch((err) => {
setRegenPending((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
setRegenNotice(err?.message || '重生提交失败,请稍后重试');
throw err;
});
}
@@ -66,6 +107,19 @@ export default function ImageSelectionPage() {
setRegenTarget({ strategy: strategy as 'A' | 'B' | 'C', role, label });
}
async function handleDeleteImage(candidateId: number) {
if (!taskId) return;
await api.delete(`/api/v1/tasks/${taskId}/image-candidates/${candidateId}`);
removeImageCandidate(candidateId);
}
// 选图即时飞轮反馈点选后绿芽提示4秒
function handleToggleImage(candidateId: number) {
toggleImageSelection(candidateId);
setJustSavedFlywheel(true);
setTimeout(() => setJustSavedFlywheel(false), 4000);
}
async function handleProceedToConfirm() {
if (!taskId || selectedImageIds.length === 0) return;
for (const candidateId of selectedImageIds) {
@@ -82,11 +136,36 @@ export default function ImageSelectionPage() {
<FlywheelBannerFromSse
recentPreference={flywheelInjected.recentPreference}
rejectReasons={flywheelInjected.rejectReasons}
signalCount={flywheelInjected.signalCount}
/>
)}
{justSavedFlywheel && (
<p role="status" aria-live="polite"
className="px-4 py-2 text-sm text-brand-green-dark bg-brand-green-light border border-brand-green/30">
🌿
</p>
)}
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
<div className="p-6 flex-1 overflow-auto">
{taskId && (
<div className="mb-4 flex justify-end">
<DeleteTaskButton taskId={taskId} />
</div>
)}
<FatalErrorBanner />
{regenNotice && (
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700">
<span>{regenNotice}</span>
<button
onClick={() => setRegenNotice(null)}
className="ml-4 text-xs text-amber-700 underline"
>
</button>
</div>
)}
{/* #4 阶段进度:配图全程阶段(分析→文案→配图→完成) */}
{!allDone && <StageProgress scope="image" />}
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
{imageCandidates.length === 0 ? (
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
@@ -100,9 +179,11 @@ export default function ImageSelectionPage() {
strategy={s}
candidates={list}
selectedImageIds={selectedImageIds}
onToggle={toggleImageSelection}
onToggle={handleToggleImage}
onZoom={setZoom}
onRegen={openRegen}
regeneratingKeys={regeneratingKeys}
onDelete={handleDeleteImage}
/>
) : null
)
@@ -110,13 +191,20 @@ export default function ImageSelectionPage() {
{/* 失败批次提示 */}
{failedBatches.length > 0 && allDone && (
<div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error">
{failedBatches.length}
{failedBatches.filter((b) => b.retryable).map((b) => (
<button key={b.batch} onClick={() => handleRetry(b.batch)}
className="ml-2 underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
{ROLE_LABEL[b.batch] || b.batch}
</button>
))}
<p className="font-medium"> {failedBatches.length} </p>
<ul className="mt-2 space-y-1">
{failedBatches.map((b) => (
<li key={b.batch} className="flex flex-wrap items-center gap-2">
<span>{ROLE_LABEL[b.batch] || b.batch}{b.reason || '中转站暂时无响应'}</span>
{b.retryable && (
<button onClick={() => handleRetry(b.batch)}
className="underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
</button>
)}
</li>
))}
</ul>
</div>
)}
{imageCandidates.length > 0 && (

View File

@@ -1,17 +1,21 @@
'use client';
// 屏3 挑文案SSE实时+五维分+多选+飞轮隐形
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
import { FlywheelBanner, FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
import { TextStrategyGroup, groupByStrategy } from '@/components/tasks/TextStrategyGroup';
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { StageProgress } from '@/components/tasks/StageProgress';
import { TextOutcomeBanner } from '@/components/tasks/TextOutcomeBanner';
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
import { useSse } from '@/hooks/useSse';
import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore';
import { usePreferenceStore } from '@/stores/preferenceStore';
import { api } from '@/lib/api';
export default function TextSelectionPage() {
@@ -19,14 +23,32 @@ export default function TextSelectionPage() {
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent } = useGenerationStore();
const { stage, textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent, removeTextCandidate } = useGenerationStore();
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
const { context, fetchContext } = usePreferenceStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId);
const [proceedError, setProceedError] = useState('');
const [track, setTrack] = useState<'ai' | 'import' | null>(null);
useEffect(() => {
if (taskId) setCurrentTask(taskId, 'generating');
}, [taskId]);
// 历史回填:无 SSE 活跃飞轮数据时,拉 task 维度 context 兜底展示
useEffect(() => {
if (!taskId || flywheelInjected) return;
fetchContext(taskId);
}, [taskId, flywheelInjected]);
// 拉取 track导入轨「去生图」要先触发生图(reuse_text)再跳页AI轨直接跳页。
// track=null 表示未拉到,按钮禁用,避免竞态误把导入轨当 AI 轨跳过生图触发。
useEffect(() => {
if (!taskId) return;
api.get<{ track?: 'ai' | 'import' }>(`/api/v1/tasks/${taskId}`)
.then((t) => setTrack(t.track === 'import' ? 'import' : 'ai'))
.catch(() => setTrack('ai'));
}, [taskId]);
async function handleSaveEdit(candidateId: number, content: string) {
if (!taskId) return;
// D1 改稿=飞轮最强信号 text_edit后端回写 + record_signal
@@ -36,43 +58,80 @@ export default function TextSelectionPage() {
patchTextCandidateContent(candidateId, content);
}
async function handleDeleteText(candidateId: number) {
if (!taskId) return;
await api.delete(`/api/v1/tasks/${taskId}/text-candidates/${candidateId}`);
removeTextCandidate(candidateId);
}
async function handleProceedToImages() {
if (!taskId || selectedTextIds.length === 0) return;
setProceedError('');
const failed: number[] = [];
for (const candidateId of selectedTextIds) {
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
try {
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
} catch {
// 不再静默吞错:残留/失效id会被后端拒(404),要让用户知道而非"以为都生效了"
failed.push(candidateId);
}
}
if (failed.length > 0) {
setProceedError(`${failed.length} 条文案选中失败(可能已失效),请重新选择后再试。`);
return;
}
// 导入轨:选完文案点「去生图」→ 触发只生图(复用导入文案),再跳生图页看进度。
// AI轨生图已在建任务时随文案一并跑完直接跳页即可。
if (track === 'import') {
try {
await api.post(`/api/v1/tasks/${taskId}/generate-images`, {});
} catch {
setProceedError('触发生图失败,请稍后重试。');
return;
}
}
router.push(`/tasks/${taskId}/images`);
}
const isGenerating = textDone < textTotal || textTotal === 0;
// task_done(stage=done)/failed 后生成已结束——绝不再显示"生成中"骨架屏装在跑,
// 否则就是图17那种"完成了却永远转圈/0条还显完成"。
const finished = stage === 'done' || stage === 'failed';
const isGenerating = !finished && (textDone < textTotal || textTotal === 0);
const showSkeleton = textCandidates.length === 0 && isGenerating;
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={2} />
<RejectReasonBanner taskId={taskId} />
{flywheelInjected && (
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
)}
{flywheelInjected ? (
<FlywheelBannerFromSse
recentPreference={flywheelInjected.recentPreference}
rejectReasons={flywheelInjected.rejectReasons}
signalCount={flywheelInjected.signalCount}
/>
) : context ? (
<FlywheelBanner context={context} />
) : null}
<div className="p-6 flex-1 overflow-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-text-primary">
{textTotal > 0 && <span className="text-sm font-normal text-text-secondary ml-2"> {textDone}/{textTotal} </span>}
</h2>
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
<div className="flex items-center gap-3">
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
{taskId && <DeleteTaskButton taskId={taskId} />}
</div>
</div>
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */}
<FatalErrorBanner />
{/* SSE 进度提示(生成中且无内容时) */}
{showSkeleton && (
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
role="status" aria-live="polite">
15-30
</div>
)}
{/* B1 文案产出结果提示:完成但没出文案/补充中/评分不可用,明确告知真实原因 */}
<TextOutcomeBanner />
{/* #3 阶段进度:替代静态"预计15-30秒",展示 分析竞品→生成文案 实时阶段 */}
{isGenerating && <StageProgress scope="text" />}
{/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */}
{showSkeleton ? (
@@ -88,6 +147,7 @@ export default function TextSelectionPage() {
selectedTextIds={selectedTextIds}
onToggle={toggleTextSelection}
onSaveEdit={handleSaveEdit}
onDelete={handleDeleteText}
/>
))
)}
@@ -97,10 +157,11 @@ export default function TextSelectionPage() {
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
<span className="text-sm text-text-secondary">
<strong className="text-text-primary">{selectedTextIds.length}</strong>
{proceedError && <span className="ml-3 text-red-600">{proceedError}</span>}
</span>
<button
onClick={handleProceedToImages}
disabled={selectedTextIds.length === 0}
disabled={selectedTextIds.length === 0 || track === null}
aria-label={`用选中的 ${selectedTextIds.length} 条文案去生图`}
className="bg-brand-orange text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
>

View File

@@ -20,7 +20,7 @@ import { ApiError } from '@/types/index';
export default function NewTaskPage() {
const router = useRouter();
const { context } = usePreferenceStore();
const { context, fetchContextByProduct } = usePreferenceStore();
const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
@@ -35,6 +35,7 @@ export default function NewTaskPage() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState(false); // 提升:供右侧「编辑配置」(运营/组长)触发就地编辑
useEffect(() => { loadProducts(); }, []);
@@ -47,6 +48,12 @@ export default function NewTaskPage() {
.catch(() => setBenchmarks([]));
}, [selectedProduct]);
// 选品变化:拉该产品飞轮偏好上下文,驱动顶部 FlywheelBanner
useEffect(() => {
if (!selectedProduct) return;
fetchContextByProduct(selectedProduct.id);
}, [selectedProduct?.id]);
async function loadProducts() {
try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
@@ -57,6 +64,12 @@ export default function NewTaskPage() {
}
}
// #1 内联建品把新产品并入列表顶部并选中QuickProductCreate 已含上传产品图)
function handleProductCreated(p: Product) {
setProducts((list) => [p, ...list.filter((x) => x.id !== p.id)]);
setSelectedProduct(p);
}
function validate(): boolean {
if (!selectedProduct) { setError('请选择产品'); return false; }
if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
@@ -135,6 +148,9 @@ export default function NewTaskPage() {
products={products}
selectedProduct={selectedProduct}
onSelectProduct={setSelectedProduct}
onProductCreated={handleProductCreated}
editingProduct={editingProduct}
setEditingProduct={setEditingProduct}
benchmarks={benchmarks}
form={form}
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
@@ -147,7 +163,7 @@ export default function NewTaskPage() {
</div>
{/* 右侧配置预览面板 */}
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} onEditInline={() => setEditingProduct(true)} />}
</div>
<ImportTextModal

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Product } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { ProductCard, ProductForm, ProductsSkeleton } from './ProductSubComponents';
import { ProductCard, ProductsSkeleton } from './ProductSubComponents';
import { ProductFormFull } from './ProductFormFull';
export function ProductsTab() {
const [products, setProducts] = useState<Product[]>([]);
@@ -70,7 +71,7 @@ export function ProductsTab() {
)}
{showForm && (
<ProductForm
<ProductFormFull
onSaved={() => { setShowForm(false); loadProducts(); }}
onCancel={() => setShowForm(false)}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,18 +4,25 @@ interface ConfirmActionBarProps {
submitting: boolean;
regenerating: boolean;
canSubmit: boolean;
hydrating?: boolean; // C1: hydrate 期间显示加载态文字,避免无提示灰死
onSubmit: () => void;
onRegenerate: () => void;
}
export function ConfirmActionBar({
submitting, regenerating, canSubmit, onSubmit, onRegenerate,
submitting, regenerating, canSubmit, hydrating = false, onSubmit, onRegenerate,
}: ConfirmActionBarProps) {
const submitLabel = hydrating
? '正在还原选中…'
: submitting
? '提交中…'
: '提交审核';
return (
<div className="flex gap-3 mt-8">
<button
onClick={onRegenerate}
disabled={regenerating || submitting}
disabled={regenerating || submitting || hydrating}
aria-label="重新生成(飞轮-1"
className="px-6 py-2.5 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary disabled:opacity-50"
>
@@ -23,11 +30,12 @@ export function ConfirmActionBar({
</button>
<button
onClick={onSubmit}
disabled={submitting || regenerating || !canSubmit}
disabled={submitting || regenerating || !canSubmit || hydrating}
aria-label="提交审核"
title={!canSubmit && !hydrating ? '请至少选择 1 条文案和 1 张图片' : undefined}
className="flex-1 bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
>
{submitting ? '提交中…' : '提交审核'}
{submitLabel}
</button>
</div>
);

View File

@@ -4,9 +4,10 @@ import { ImageCandidate } from '@/types/index';
interface ConfirmPreviewImagesProps {
images: ImageCandidate[];
onZoom?: (img: ImageCandidate) => void; // 传入则图片可点击放大(已通过只读查看用)
}
export function ConfirmPreviewImages({ images }: ConfirmPreviewImagesProps) {
export function ConfirmPreviewImages({ images, onZoom }: ConfirmPreviewImagesProps) {
return (
<div>
<h3 className="text-sm font-semibold text-text-secondary mb-3"></h3>
@@ -15,7 +16,11 @@ export function ConfirmPreviewImages({ images }: ConfirmPreviewImagesProps) {
) : (
<div className="grid grid-cols-2 gap-2">
{images.map((img) => (
<div key={img.candidate_id} className="rounded-xl overflow-hidden border border-border-default">
<div
key={img.candidate_id}
className={`rounded-xl overflow-hidden border border-border-default ${onZoom ? 'cursor-zoom-in' : ''}`}
onClick={onZoom ? () => onZoom(img) : undefined}
>
<img src={img.url} alt={`选中图片·${img.role}`}
className="w-full aspect-square object-cover" />
<p className="text-xs text-text-secondary text-center py-1">{img.role}</p>

View File

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

View File

@@ -1,38 +1,65 @@
'use client';
/**
* ImageProgressHeader — 生图进度标题 + 进度条
* 用于屏4"挑图"顶部,能离开提示
* ImageProgressHeader — 生图多步骤阶段进度A1
* 数据来自 generationStore用真实 SSE imageDone 推算当前阶段
*/
interface ImageProgressHeaderProps {
import { useGenerationStore } from '@/stores/generationStore';
import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
const IMAGE_STAGES = [
{ label: '整理产品图和参考图' },
{ label: '规划点击-证明-转化分镜' },
{ label: '逐张生成 3:4 图片' },
{ label: '检查是否出现 App 截图/底栏' },
{ label: '整理图片预览和同步包' },
];
// 根据 imageDone/imageTotal 推算当前激活阶段
function calcImageStageIdx(done: number, total: number, stage: string): number {
if (stage === 'idle' || stage === 'analyzing' || stage === 'text') return 0;
if (stage === 'done') return IMAGE_STAGES.length; // 全完成
// 生图阶段0=整理图, 1=规划分镜, 2=逐张生成(主阶段), 3=检查, 4=整理包
if (done === 0) return 1; // 还没出图:规划阶段
if (done < total) return 2; // 出图中:逐张生成
if (done >= total && total > 0) return 4; // 全出完:整理包(最后一步)
return 2;
}
interface Props {
imageTotal: number;
imageDone: number;
pendingCount: number;
}
export function ImageProgressHeader({ imageTotal, imageDone, pendingCount }: ImageProgressHeaderProps) {
const pct = imageTotal > 0 ? Math.round((imageDone / imageTotal) * 100) : 0;
export function ImageProgressHeader({ imageTotal, imageDone, pendingCount }: Props) {
const { stage, startedAt } = useGenerationStore();
const allDone = imageTotal > 0 && pendingCount === 0;
const activeIdx = calcImageStageIdx(imageDone, imageTotal, stage);
// 真实进度比例(出完再跳到 100%
const realPct = allDone
? 100
: imageTotal > 0
? Math.min(96, Math.round((imageDone / imageTotal) * 85 + 14))
: undefined;
// 骨架屏:还未收到图时展示占位
const skeletonCount = imageCandidatesEmpty(imageDone) ? 6 : 0;
return (
<>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-text-primary"></h2>
<p className="text-sm text-text-secondary mt-0.5">
{imageTotal} {imageDone}/{imageTotal}
{pendingCount > 0 && ' · 可先去忙别的,好了会更新'}
</p>
</div>
{imageTotal > 0 && (
<span className="text-lg font-bold text-brand-orange">{imageDone}/{imageTotal}</span>
)}
</div>
{imageTotal > 0 && (
<div className="mb-4 h-2 bg-surface-tertiary rounded-full overflow-hidden"
role="progressbar" aria-label={`图片生成进度 ${imageDone}/${imageTotal}`}
aria-valuenow={imageDone} aria-valuemin={0} aria-valuemax={imageTotal}>
<div className="h-full bg-brand-orange rounded-full transition-all"
style={{ width: `${pct}%` }} />
</div>
)}
</>
<StageLoadingPanel
title={allDone ? `已生成 ${imageTotal} 张图` : `正在生成图片 ${imageDone}/${imageTotal || '…'}`}
stages={IMAGE_STAGES}
activeIdx={activeIdx}
targetSec={62}
startedAt={startedAt}
realPct={realPct}
skeletonCount={skeletonCount}
className="mb-4"
/>
);
}
function imageCandidatesEmpty(done: number) {
return done === 0;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,74 @@ import { useTaskStore } from '@/stores/taskStore';
import { TextCandidate, ImageCandidate } from '@/types/index';
import { api } from '@/lib/api';
const DONE_STATUSES = ['pending_selection', 'pending_review', 'approved', 'rejected', 'archived'];
// 模块级"上一个任务id":组件内 useRef 在 SPA 切页时随组件重建归零,
// 无法区分"切任务"还是"同任务跨页",导致清选中条件永不命中(残留串任务)。
// 提到模块级后跨组件实例存活:同任务跨页(文案→图片→确认)不清,切新任务才清。
let lastSeenTaskId: number | null = null;
type TaskSnapshot = {
status?: string;
text_count?: number;
image_count?: number;
text_candidates?: TextCandidate[];
image_candidates?: ImageCandidate[];
};
function imageTotalFromTask(task: TaskSnapshot, images: ImageCandidate[]): number {
if (typeof task.image_count === 'number' && task.image_count > 0) {
return task.image_count * 3; // A/B/C 三套图,每套 image_count 张
}
return images.length || 0;
}
function applyTaskSnapshot(
task: TaskSnapshot,
store: ReturnType<typeof useGenerationStore.getState>,
) {
const texts = task.text_candidates ?? [];
const images = task.image_candidates ?? [];
if (texts.length || images.length) {
store.hydrateFromTask({ textCandidates: texts, imageCandidates: images });
}
const textTarget = task.text_count ?? texts.length;
const imageTarget = imageTotalFromTask(task, images);
store.setTextProgress(Math.min(texts.length, textTarget), textTarget);
store.setImageProgress(Math.min(images.length, imageTarget), imageTarget);
// 历史/已完成任务 SSE 不重放 task_done进行中任务也可能漏 SSE。
// 所以快照是最终校准源,避免页面停在 0/4 或完成后又被迟到事件拉回生成中。
if (task.status === 'failed') {
store.setStage('failed');
return;
}
if (task.status && DONE_STATUSES.includes(task.status)) {
store.setStage('done');
if (texts.length === 0) {
store.setTextOutcome({
saved: 0,
target: textTarget,
reason: 'quality_filtered',
});
} else {
store.setTextOutcome({
saved: texts.length,
target: textTarget,
reason: texts.length >= textTarget ? null : 'replenishing',
});
}
return;
}
if (images.length > 0) {
store.setStage('image');
} else if (texts.length > 0) {
store.setStage('text');
}
}
export function useSse(taskId: number | null) {
const clientRef = useRef<SseClient | null>(null);
const store = useGenerationStore();
@@ -20,24 +88,23 @@ export function useSse(taskId: number | null) {
useEffect(() => {
if (!taskId) return;
store.reset();
// 进入任务时清空上一个任务残留的选中态,避免"交审"按钮基于陈旧选择误判可点/置灰
useTaskStore.getState().clearSelections();
// 仅在切换到不同任务时才清选中态,同任务在不同页间跳转(文案页→图片页)不清。
// lastSeenTaskId 为模块级SPA 切页不归零,故 null→新任务 也会清(修复残留)。
if (lastSeenTaskId !== taskId) {
useTaskStore.getState().clearSelections();
}
lastSeenTaskId = taskId;
// reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。
// 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成
// reset 把 hydrate 数据清空的竞态SPA 跳转进来会看到空白)。
let cancelled = false;
api.get<{
text_candidates?: TextCandidate[];
image_candidates?: ImageCandidate[];
}>(`/api/v1/tasks/${taskId}`).then((task) => {
const loadSnapshot = () => api.get<TaskSnapshot>(`/api/v1/tasks/${taskId}`).then((task) => {
if (cancelled) return;
const texts = task.text_candidates ?? [];
const images = task.image_candidates ?? [];
if (texts.length || images.length) {
store.hydrateFromTask({ textCandidates: texts, imageCandidates: images });
}
applyTaskSnapshot(task, store);
}).catch(() => { /* 进行中任务靠 SSE 填充 */ });
loadSnapshot();
const snapshotTimer = window.setInterval(loadSnapshot, 5000);
const client = createSseClient(taskId, {
onEvent: (event: SseEvent) => handleEvent(event, store, taskId),
@@ -49,6 +116,7 @@ export function useSse(taskId: number | null) {
return () => {
cancelled = true;
window.clearInterval(snapshotTimer);
client.close();
clientRef.current = null;
};
@@ -65,17 +133,28 @@ function handleEvent(
store: ReturnType<typeof useGenerationStore.getState>,
taskId: number,
) {
const currentStage = useGenerationStore.getState().stage;
const isTerminal = currentStage === 'done' || currentStage === 'failed';
switch (event.type) {
case 'task_started':
store.setTextProgress(0, event.data.total_text);
store.setImageProgress(0, event.data.total_image);
store.setStartedAt(Date.now()); // A1/A2 ETA 计算起点
// task_started→首条文案之间后端在做竞品/飞轮分析,先停"分析竞品"阶段;
// 文案事件一到立即推进到 text(后端无独立 analyze_done,靠文案事件兜底不会卡死)
store.setStage('analyzing');
break;
case 'text_progress':
if (isTerminal) break;
store.setStage('text');
store.setTextProgress(event.data.done, event.data.total);
break;
case 'text_candidate': {
if (isTerminal) break;
store.setStage('text'); // 文案候选先于 text_progress 到时,也立即脱离 analyzing
// SSE 只推 total 分;先用 total 占位,再异步补全五维分
const totalScore = typeof event.data.score === 'number' ? event.data.score : 0;
const tc: TextCandidate = {
@@ -99,10 +178,13 @@ function handleEvent(
}
case 'image_progress':
if (isTerminal) break;
store.setStage('image');
store.setImageProgress(event.data.done, event.data.total);
break;
case 'image_candidate': {
if (isTerminal) break;
const ic: ImageCandidate = {
candidate_id: event.data.candidate_id,
strategy: event.data.strategy,
@@ -125,17 +207,32 @@ function handleEvent(
store.setFlywheelInjected({
recentPreference: event.data.recent_preference,
rejectReasons: event.data.reject_reasons,
signalCount: event.data.signal_count,
});
break;
case 'error':
// 任务级致命错误:不再静默丢弃,落 fatalError 让 UI 提示(B6)
store.setStage('failed');
store.setFatalError(event.data.code, event.data.message);
break;
case 'heartbeat':
case 'analyze_done':
// 竞品/标杆分析完成 → 进入文案阶段(此前停在 analyzing
store.setStage('text');
break;
case 'task_done':
store.setStage('done');
// B1落定文案产出结果前端据此区分"完成/补充中/评分不可用/质量不合格"
store.setTextOutcome({
saved: event.data.text_saved ?? store.textCandidates.length,
target: event.data.text_target ?? 0,
reason: event.data.text_reason ?? null,
});
break;
case 'heartbeat':
break;
}
}
}

View File

@@ -30,7 +30,11 @@ async function refreshToken(): Promise<string | null> {
try {
// 走 /auth/refresh二期实现一期降级跳登录
clearToken();
window.location.href = '/login';
// 带提示跳登录,避免“弹窗一闪就被踢、看不清原因”
if (typeof window !== 'undefined') {
const back = encodeURIComponent(window.location.pathname);
window.location.href = `/login?reason=expired&from=${back}`;
}
return null;
} finally {
refreshPromise = null;
@@ -67,7 +71,13 @@ async function request<T>(
}
}
throw err;
const apiError = new Error(err.message || `请求失败(${err.code || res.status})`) as Error & {
code?: number;
status?: number;
};
apiError.code = err.code;
apiError.status = res.status;
throw apiError;
}
// ─── 公开方法 ────────────────────────────────────────────────

View File

@@ -70,11 +70,18 @@ export class SseClient {
// SSE 必须直连后端,绕过 Next dev rewrite 代理——后者会缓冲/吞掉 EventSource 的
// 流式事件(实测:经代理 EventSource 只触发 open零事件直连后端事件正常
// NEXT_PUBLIC_SSE_BASE_URL 缺省时退回 NEXT_PUBLIC_API_BASE_URL再退回相对路径。
const sseBase = (
let sseBase = (
process.env.NEXT_PUBLIC_SSE_BASE_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
''
).replace(/\/$/, '');
if (
typeof window !== 'undefined' &&
process.env.NODE_ENV === 'development' &&
(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost')
) {
sseBase = `http://${window.location.hostname}:8000`;
}
const url = `${sseBase}/api/v1/tasks/${this.taskId}/stream?ticket=${ticket}&last_seq=${lastSeq}`;
this.es = new EventSource(url);
this.es.onopen = () => this.options.onStatusChange('connected');

View File

@@ -5,6 +5,8 @@
import { create } from 'zustand';
import { SseConnectionStatus } from '@/lib/sse';
import { TextCandidate, ImageCandidate } from '@/types/index';
// 懒引入 taskStore避免循环依赖taskStore 不引 generationStore
import { useTaskStore } from '@/stores/taskStore';
interface FailedBatch {
batch: string; // 图片角色名("hook"/"pain"等),对应 BE batch_failed.batch 字段
@@ -12,11 +14,32 @@ interface FailedBatch {
retryable: boolean;
}
// 生成阶段(驱动阶段进度条,对齐产品包"有阶段进度"体验 #3#4
// idle→分析竞品→生成文案→配图→完成failed 单列
export type GenStage = 'idle' | 'analyzing' | 'text' | 'image' | 'done' | 'failed';
// 文案产出结果原因B1——task_done 透传,区分"完成但没出文案"的真实原因
export type TextOutcomeReason =
| 'scoring_unavailable' // 评分服务不可用apiports+codeproxy都挂
| 'generation_failed' // 文案生成本身失败
| 'quality_filtered' // 生成了但全部未达标
| 'replenishing' // 有产出但不足,后台补充中
| null; // 正常足量
export interface TextOutcome {
saved: number;
target: number;
reason: TextOutcomeReason;
}
interface GenerationState {
// 连接状态
connectionStatus: SseConnectionStatus;
reconnectAttempt: number;
// 当前生成阶段(#3#4 阶段进度)
stage: GenStage;
// 文案生成进度
textDone: number;
textTotal: number;
@@ -35,18 +58,30 @@ interface GenerationState {
flywheelInjected: {
recentPreference: string;
rejectReasons: string[];
signalCount?: number;
} | null;
// 任务启动时间戳mstask_started 事件到达时记录,用于 ETA 推算A1/A2
startedAt: number | null;
// 文案产出结果B1——task_done 到达时落定,驱动"完成但没出文案"的明确提示
textOutcome: TextOutcome | null;
setConnectionStatus: (s: SseConnectionStatus, attempt?: number) => void;
setStage: (stage: GenStage) => void;
setStartedAt: (ts: number) => void;
setTextOutcome: (outcome: TextOutcome) => void;
setTextProgress: (done: number, total: number) => void;
addTextCandidate: (candidate: TextCandidate) => void;
patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void;
patchTextCandidateContent: (candidateId: number, content: string) => void;
removeTextCandidate: (candidateId: number) => void;
removeImageCandidate: (candidateId: number) => void;
setImageProgress: (done: number, total: number) => void;
addImageCandidate: (candidate: ImageCandidate) => void;
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
setFatalError: (code: number, message: string) => void;
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[] }) => void;
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[]; signalCount?: number }) => void;
hydrateFromTask: (data: {
textCandidates: TextCandidate[];
imageCandidates: ImageCandidate[];
@@ -57,6 +92,9 @@ interface GenerationState {
export const useGenerationStore = create<GenerationState>((set) => ({
connectionStatus: 'connecting',
reconnectAttempt: 0,
stage: 'idle',
startedAt: null,
textOutcome: null,
textDone: 0,
textTotal: 0,
textCandidates: [],
@@ -71,12 +109,31 @@ export const useGenerationStore = create<GenerationState>((set) => ({
set({ connectionStatus: s, reconnectAttempt: attempt });
},
setStage(stage) {
set({ stage });
},
setStartedAt(ts) {
set({ startedAt: ts });
},
setTextOutcome(outcome) {
set({ textOutcome: outcome });
},
setTextProgress(done, total) {
set({ textDone: done, textTotal: total });
},
addTextCandidate(candidate) {
set((s) => ({ textCandidates: [...s.textCandidates, candidate] }));
set((s) => {
const exists = s.textCandidates.some((tc) => tc.candidate_id === candidate.candidate_id);
return {
textCandidates: exists
? s.textCandidates.map((tc) => tc.candidate_id === candidate.candidate_id ? { ...tc, ...candidate } : tc)
: [...s.textCandidates, candidate],
};
});
},
patchTextCandidateScore(candidateId, score) {
@@ -95,12 +152,34 @@ export const useGenerationStore = create<GenerationState>((set) => ({
}));
},
removeTextCandidate(candidateId) {
set((s) => ({
textCandidates: s.textCandidates.filter((tc) => tc.candidate_id !== candidateId),
}));
// 同步清选中态
useTaskStore.getState().deselectText(candidateId);
},
removeImageCandidate(candidateId) {
set((s) => ({
imageCandidates: s.imageCandidates.filter((ic) => ic.candidate_id !== candidateId),
}));
useTaskStore.getState().deselectImage(candidateId);
},
setImageProgress(done, total) {
set({ imageDone: done, imageTotal: total });
},
addImageCandidate(candidate) {
set((s) => ({ imageCandidates: [...s.imageCandidates, candidate] }));
set((s) => {
const exists = s.imageCandidates.some((ic) => ic.candidate_id === candidate.candidate_id);
return {
imageCandidates: exists
? s.imageCandidates.map((ic) => ic.candidate_id === candidate.candidate_id ? { ...ic, ...candidate } : ic)
: [...s.imageCandidates, candidate],
};
});
},
markBatchFailed(batch, reason, retryable) {
@@ -118,6 +197,7 @@ export const useGenerationStore = create<GenerationState>((set) => ({
},
// 进入已完成/进行中任务时一次性回填(历史任务 SSE 不重放,必须主动拉接口)
// 同时把带 is_selected 标记的候选 id 写进 taskStore修复跨页选中态丢失B2
hydrateFromTask({ textCandidates, imageCandidates }) {
set({
textCandidates,
@@ -127,14 +207,31 @@ export const useGenerationStore = create<GenerationState>((set) => ({
imageDone: imageCandidates.length,
imageTotal: imageCandidates.length || 0,
});
// 若候选带 is_selected 字段,同步还原 taskStore 选中态
const selectedTexts = textCandidates
.filter((c) => (c as TextCandidate & { is_selected?: boolean }).is_selected)
.map((c) => c.candidate_id);
const selectedImages = imageCandidates
.filter((c) => (c as ImageCandidate & { is_selected?: boolean }).is_selected)
.map((c) => c.candidate_id);
// 方案B倩倩姐2026-06-26拍板选中回填只做「首次进页恢复历史」。
// 本地已有选中=用户正在挑(图片/文案选中只存本地、点"去生图/去确认"才提交后端),
// 此时 5 秒轮询的后端快照绝不能整体覆盖本地,否则未提交的选中过几秒就被抹掉。
const taskState = useTaskStore.getState();
const localHasSelection =
taskState.selectedTextIds.length > 0 || taskState.selectedImageIds.length > 0;
if (!localHasSelection && (selectedTexts.length || selectedImages.length)) {
taskState.setSelections(selectedTexts, selectedImages);
}
},
reset() {
set({
connectionStatus: 'connecting', reconnectAttempt: 0,
connectionStatus: 'connecting', reconnectAttempt: 0, stage: 'idle',
textDone: 0, textTotal: 0, textCandidates: [],
imageDone: 0, imageTotal: 0, imageCandidates: [],
failedBatches: [], fatalError: null, flywheelInjected: null,
failedBatches: [], fatalError: null, flywheelInjected: null, startedAt: null,
textOutcome: null,
});
},
}));

View File

@@ -13,6 +13,7 @@ interface PreferenceState {
taskId: number | null;
fetchContext: (taskId: number) => Promise<void>;
fetchContextByProduct: (productId: number) => Promise<void>;
clear: () => void;
}
@@ -34,6 +35,19 @@ export const usePreferenceStore = create<PreferenceState>((set) => ({
}
},
async fetchContextByProduct(productId) {
// 新建任务页用:此时还没有 task_id按产品维度取累计偏好
set({ isLoading: true });
try {
const data = await api.get<PreferenceContext>(
`/api/v1/products/${productId}/preference/context`
);
set({ context: data, isLoading: false });
} catch {
set({ isLoading: false });
}
},
clear() {
set({ context: null, taskId: null });
},

View File

@@ -19,6 +19,9 @@ interface TaskState {
setRecentTasks: (tasks: TaskListItem[]) => void;
toggleTextSelection: (candidateId: number) => void;
toggleImageSelection: (candidateId: number) => void;
deselectText: (candidateId: number) => void;
deselectImage: (candidateId: number) => void;
setSelections: (textIds: number[], imageIds: number[]) => void;
clearSelections: () => void;
reset: () => void;
}
@@ -62,6 +65,19 @@ export const useTaskStore = create<TaskState>((set) => ({
set({ selectedTextIds: [], selectedImageIds: [] });
},
deselectText(candidateId) {
set((s) => ({ selectedTextIds: s.selectedTextIds.filter((id) => id !== candidateId) }));
},
deselectImage(candidateId) {
set((s) => ({ selectedImageIds: s.selectedImageIds.filter((id) => id !== candidateId) }));
},
// 历史任务直接进确认页时,用后端 is_selected 一次性还原已选store 无持久化,刷新/直达会丢)
setSelections(textIds, imageIds) {
set({ selectedTextIds: textIds, selectedImageIds: imageIds });
},
reset() {
set({
currentTaskId: null,

View File

@@ -61,6 +61,7 @@ export interface TextCandidate {
verdict?: string; // AI评委总评"优秀"|"合格"|"不合格",旧数据为空字符串
summary?: string; // AI评委一句话总评(含改进点),旧数据为空字符串
edited?: boolean; // 是否被运营改过稿D1 飞轮最强信号)
is_selected?: boolean; // 后端落库的已选标记;历史任务直达确认页时据此还原已选
}
export interface SelectCandidateRequest { candidate_id: number; }
@@ -89,8 +90,27 @@ export interface ReviewQueueItem {
theme: string;
submitted_at: string;
operator_name: string;
selected_text: { candidate_id: number; angle_label: string; title?: string; content: string; tags?: string[]; score?: TextScore | null; };
selected_image: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; };
selected_text: { candidate_id: number; angle_label: string; title?: string; content: string; tags?: string[]; score?: TextScore | null; } | null;
selected_image: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; } | null;
selected_sets?: Array<{
strategy: 'A' | 'B' | 'C';
selected_text: {
candidate_id: number;
strategy?: 'A' | 'B' | 'C';
angle_label: string;
title?: string;
content: string;
tags?: string[];
score?: TextScore | null;
} | null;
selected_images: Array<{
candidate_id: number;
strategy: 'A' | 'B' | 'C';
url: string;
role?: string;
seq?: number;
} | null>;
}>;
}
export interface RejectRequest { reason: string; }
@@ -100,6 +120,7 @@ export interface PreferenceContext {
recent_preference: string;
reject_reasons: string[];
injected_count: number;
signal_count?: number; // 该产品累计信号总数("飞轮已积累N条信号"
}
// 交付包

View File

@@ -9,6 +9,7 @@ export interface User {
email: string;
current_workspace_id: number;
role: UserRole;
must_change_password?: boolean;
}
export interface Workspace {
@@ -59,6 +60,8 @@ export interface Product {
banned_word_ids: number[];
image_path: string | null; // 产品参考图路径(铁律:生图带产品图,向后兼容=主图)
images?: ProductImage[]; // R5多图每张带 scene 场景类型
brand_keyword?: string | null; // 012:套2字段 品牌词,客户录入
target_audience?: string | null; // 012:套2字段 目标人群
}
export interface CreateProductRequest {
@@ -70,20 +73,24 @@ export interface CreateProductRequest {
text_angles: string[];
custom_prompt: string | null;
banned_word_ids: number[];
brand_keyword?: string | null; // 012:套2字段 品牌词,新建/编辑均可填
target_audience?: string | null; // 012:套2字段 目标人群,新建/编辑均可填
}
// 标杆笔记
export interface Benchmark {
id: number;
screenshot_url: string;
screenshot_url: string | null;
highlights: string;
link_url: string | null;
analyze_status?: 'pending' | 'analyzing' | 'done' | 'failed';
features?: Record<string, unknown> | null;
analysis_source?: string | null;
analysis_warning?: string | null;
}
export interface CreateBenchmarkRequest {
screenshot_url: string;
screenshot_url?: string | null;
highlights: string;
link_url: string | null;
}

View File

@@ -43,7 +43,12 @@ export interface ImageCandidateEvent extends SseBaseEvent {
export interface FlywheelInjectedEvent extends SseBaseEvent {
type: 'flywheel_injected';
data: { recent_preference: string; reject_reasons: string[] };
data: {
recent_preference: string;
reject_reasons: string[];
injected_count?: number; // 本次注入的有效信号数
signal_count?: number; // 该产品累计信号总数("飞轮已积累N条"
};
}
export interface BatchFailedEvent extends SseBaseEvent {
@@ -53,7 +58,13 @@ export interface BatchFailedEvent extends SseBaseEvent {
export interface TaskDoneEvent extends SseBaseEvent {
type: 'task_done';
data: { task_id: number; status: string };
data: {
task_id: number; status: string;
// B1文案产出结果前端据此区分"评分不可用/质量不合格/补充中/正常"
text_saved?: number;
text_target?: number;
text_reason?: 'scoring_unavailable' | 'generation_failed' | 'quality_filtered' | 'replenishing' | null;
};
}
export interface SseErrorEvent extends SseBaseEvent {