上线版: 产品表单统一+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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
94
frontend/src/app/change-password/page.tsx
Normal file
94
frontend/src/app/change-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 🔴 红线:key 各人自录自用各算各的;只录不显余额
|
||||
* 拆分自原 /config(B方案):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">
|
||||
|
||||
@@ -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报bug):SPA 路由切到不同任务卡片时,
|
||||
// 上个任务数据仍在 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user