上线版: 产品表单统一+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