baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
58
frontend/src/app/config/page.tsx
Normal file
58
frontend/src/app/config/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
/**
|
||||
* 屏1 配置台(管理员 /config)
|
||||
* 产品库 / 标杆笔记 / 违禁词 / API Key 录入
|
||||
* 🔴 红线:key 只录不显余额(不做 Token 用量展示)
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { ProductsTab } from '@/components/config/ProductsTab';
|
||||
import { BenchmarksTab } from '@/components/config/BenchmarksTab';
|
||||
import { BannedWordsTab } from '@/components/config/BannedWordsTab';
|
||||
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
|
||||
|
||||
type TabKey = 'products' | 'benchmarks' | 'banned_words' | 'api_keys';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'products', label: '产品档案' },
|
||||
{ key: 'benchmarks', label: '标杆笔记' },
|
||||
{ key: 'banned_words', label: '违禁词库' },
|
||||
{ key: 'api_keys', label: 'API Key' },
|
||||
];
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('products');
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6">配置台</h2>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex gap-1 border-b border-border-default mb-6" role="tablist">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
aria-controls={`panel-${tab.key}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'bg-surface-primary border border-border-default border-b-white text-brand-orange'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<div id={`panel-${activeTab}`} role="tabpanel">
|
||||
{activeTab === 'products' && <ProductsTab />}
|
||||
{activeTab === 'benchmarks' && <BenchmarksTab />}
|
||||
{activeTab === 'banned_words' && <BannedWordsTab />}
|
||||
{activeTab === 'api_keys' && <ApiKeysTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/app/dashboard/page.tsx
Normal file
51
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
/**
|
||||
* /dashboard — 工作台(各角色登录后的中转落地页)
|
||||
* 按角色展示对应入口
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, role } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-2">
|
||||
你好,{user?.username ?? '朋友'}
|
||||
</h2>
|
||||
<p className="text-text-secondary text-sm mb-8">
|
||||
{role === 'admin' && '你是管理员,可管理产品配置和审核'}
|
||||
{role === 'supervisor' && '你是组长,负责审核待发笔记'}
|
||||
{role === 'operator' && '今天要生成什么主题?'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{role !== 'supervisor' && (
|
||||
<DashCard href="/tasks/new" icon="✏️" label="开新任务" desc="选产品,输今天主题,开始生成" />
|
||||
)}
|
||||
{(role === 'supervisor' || role === 'admin') && (
|
||||
<DashCard href="/review" icon="✅" label="审核台" desc="查看待审笔记,通过或打回" />
|
||||
)}
|
||||
{role === 'admin' && (
|
||||
<DashCard href="/config" icon="⚙️" label="配置中心" desc="管理产品、标杆笔记、违禁词和 API Key" />
|
||||
)}
|
||||
<DashCard href="/history" icon="🗂️" label="历史归档" desc="查看历史任务和成品库" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashCard({ href, icon, label, desc }: {
|
||||
href: string; icon: string; label: string; desc: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}
|
||||
aria-label={label}
|
||||
className="border border-border-default rounded-xl p-5 bg-surface-primary hover:border-brand-orange hover:shadow-sm transition-all group">
|
||||
<span className="text-3xl block mb-2" aria-hidden="true">{icon}</span>
|
||||
<h3 className="font-semibold text-text-primary text-sm group-hover:text-brand-orange">{label}</h3>
|
||||
<p className="text-xs text-text-secondary mt-1">{desc}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
8
frontend/src/app/globals.css
Normal file
8
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 最小宽度 1280px,不做移动端 */
|
||||
body {
|
||||
min-width: 1280px;
|
||||
}
|
||||
174
frontend/src/app/history/components.tsx
Normal file
174
frontend/src/app/history/components.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
/**
|
||||
* HistoryTable / HistorySkeleton / Pagination / DownloadButton
|
||||
* 历史归档页子组件(按约束拆出 ≤100行)
|
||||
*
|
||||
* C1修复:下载走 fetch+blob,不用 window.open。
|
||||
* window.open 无法附带 Authorization header,会 401。
|
||||
* 改法:api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { clsx } from 'clsx';
|
||||
import { TaskListItem } from '@/types';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
|
||||
approved: { text: '已通过', color: 'text-green-600 bg-green-50' },
|
||||
archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' },
|
||||
rejected: { text: '被打回', color: 'text-red-500 bg-red-50' },
|
||||
};
|
||||
|
||||
// --- DownloadButton --- 触发打包→轮询→下载 zip
|
||||
function DownloadButton({ taskId }: { taskId: number }) {
|
||||
const [state, setState] = useState<'idle' | 'packaging' | 'done' | 'error'>('idle');
|
||||
const [pkgId, setPkgId] = useState<number | null>(null);
|
||||
|
||||
async function handleDownload() {
|
||||
setState('packaging');
|
||||
try {
|
||||
// 1. 触发打包
|
||||
const pkg = await api.post<{ delivery_package_id: number }>(`/api/v1/tasks/${taskId}/package`, {});
|
||||
const id = pkg.delivery_package_id;
|
||||
setPkgId(id);
|
||||
// 2. 轮询 status(最多20次,每2s)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const info = await api.get<{ status: string }>(`/api/v1/delivery-packages/${id}/download`);
|
||||
if (info.status === 'ready' || info.status === 'downloaded') {
|
||||
// 3. fetch+blob 下载(带 Authorization header,避免 window.open 401)
|
||||
const blob = await api.getBlob(`/api/v1/delivery-packages/${id}/download-file`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `delivery-${id}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setState('done');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState('error');
|
||||
} catch { setState('error'); }
|
||||
}
|
||||
|
||||
if (state === 'done') return <span className="text-xs text-green-600">已下载</span>;
|
||||
if (state === 'error') return <span className="text-xs text-red-500">失败,刷新重试</span>;
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={state === 'packaging'}
|
||||
className="text-xs text-brand-orange hover:underline disabled:text-text-tertiary"
|
||||
>
|
||||
{state === 'packaging' ? '打包中…' : '下载交付包'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- HistoryTable ---
|
||||
export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border-default">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-secondary text-text-secondary">
|
||||
<th className="px-4 py-3 text-left font-medium">任务</th>
|
||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left font-medium">文案</th>
|
||||
<th className="px-4 py-3 text-left font-medium">图片</th>
|
||||
<th className="px-4 py-3 text-left font-medium">创建时间</th>
|
||||
<th className="px-4 py-3 text-left font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task, i) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={clsx(
|
||||
'border-t border-border-default hover:bg-surface-tertiary transition-colors',
|
||||
i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate">
|
||||
{task.theme}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
STATUS_LABEL[task.status]?.color ?? 'text-gray-500 bg-gray-100'
|
||||
)}>
|
||||
{STATUS_LABEL[task.status]?.text ?? task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{task.text_count} 条</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{task.image_count} 张</td>
|
||||
<td className="px-4 py-3 text-text-tertiary">
|
||||
{new Date(task.created_at).toLocaleDateString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/tasks/${task.id}/confirm`}
|
||||
className="text-brand-orange hover:underline text-xs"
|
||||
>
|
||||
查看
|
||||
</Link>
|
||||
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- HistorySkeleton ---
|
||||
export function HistorySkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border border-border-default overflow-hidden animate-pulse">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4 px-4 py-3 border-t border-border-default first:border-t-0">
|
||||
<div className="h-4 bg-gray-200 rounded flex-1" />
|
||||
<div className="h-4 bg-gray-200 rounded w-16" />
|
||||
<div className="h-4 bg-gray-200 rounded w-12" />
|
||||
<div className="h-4 bg-gray-200 rounded w-12" />
|
||||
<div className="h-4 bg-gray-200 rounded w-24" />
|
||||
<div className="h-4 bg-gray-200 rounded w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Pagination ---
|
||||
export function Pagination({
|
||||
page, total, onChange,
|
||||
}: { page: number; total: number; onChange: (p: number) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2" aria-label="分页">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
className="px-3 py-1 rounded border border-border-default text-sm disabled:opacity-40 hover:bg-surface-tertiary"
|
||||
aria-label="上一页"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-sm text-text-secondary px-2">
|
||||
第 {page} / {total} 页
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= total}
|
||||
onClick={() => onChange(page + 1)}
|
||||
className="px-3 py-1 rounded border border-border-default text-sm disabled:opacity-40 hover:bg-surface-tertiary"
|
||||
aria-label="下一页"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/app/history/page.tsx
Normal file
95
frontend/src/app/history/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
/**
|
||||
* 历史归档 /history
|
||||
* 角色:熟手 + 组长(管理员也能进)
|
||||
* 数据:GET /api/v1/tasks?status=approved,archived,rejected
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { TaskListItem, PaginatedResponse } from '@/types';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
import { clsx } from 'clsx';
|
||||
import { HistoryTable, HistorySkeleton, Pagination } from './components';
|
||||
|
||||
const HISTORY_STATUSES = ['approved', 'archived', 'rejected'];
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
approved: '已通过', archived: '已归档', rejected: '被打回',
|
||||
};
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
const fetchHistory = useCallback(async (p: number, status: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) });
|
||||
if (status !== 'all') params.append('status', status);
|
||||
else HISTORY_STATUSES.forEach((s) => params.append('status', s));
|
||||
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
|
||||
setTasks(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (e: unknown) {
|
||||
const ae = e as { code?: number };
|
||||
setError(getErrorAction(ae.code ?? 50001).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchHistory(page, statusFilter); }, [page, statusFilter, fetchHistory]);
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
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>
|
||||
<span className="text-sm text-text-tertiary">共 {total} 条</span>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex gap-2" role="group" aria-label="状态筛选">
|
||||
{[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({
|
||||
key: s, label: STATUS_LABELS[s],
|
||||
}))].map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => { setStatusFilter(opt.key); setPage(1); }}
|
||||
className={clsx(
|
||||
'px-3 py-1 rounded-full text-sm border transition-colors',
|
||||
statusFilter === opt.key
|
||||
? 'bg-brand-orange text-white border-brand-orange'
|
||||
: 'border-border-default text-text-secondary hover:border-brand-orange'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||
{error}
|
||||
<button className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <HistorySkeleton /> : tasks.length === 0 ? (
|
||||
<div className="py-20 text-center text-text-tertiary text-sm">暂无历史记录</div>
|
||||
) : (
|
||||
<HistoryTable tasks={tasks} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && <Pagination page={page} total={totalPages} onChange={setPage} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/src/app/layout.tsx
Normal file
49
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '龙石小红书内容生产平台',
|
||||
description: '小红书内容生产工具',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="bg-surface-secondary text-text-primary">
|
||||
<AuthGuard>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{/* 顶部 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">
|
||||
龙石小红书内容生产平台
|
||||
</h1>
|
||||
<UserBadge />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
/** 右上角当前用户展示(Server Component 不可用 Store,Client 组件内联) */
|
||||
function UserBadge() {
|
||||
// 通过 Client 组件获取 store 数据,此处为 Server Component 占位
|
||||
return <UserBadgeClient />;
|
||||
}
|
||||
|
||||
// 内联 Client 组件,避免额外文件
|
||||
import UserBadgeClient from '@/components/layout/UserBadge';
|
||||
86
frontend/src/app/login/page.tsx
Normal file
86
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
// /login — 登录页,按角色跳转首页(admin→/config, supervisor→/review, operator→/tasks/new)
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { LoginRequest, LoginResponse, ApiError } from '@/types/index';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
|
||||
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 LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, role } = useAuthStore();
|
||||
const [form, setForm] = useState<LoginRequest>({ username: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.username || !form.password) {
|
||||
setError('请填写用户名和密码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.post<LoginResponse>('/api/v1/auth/login', form);
|
||||
login(data.token, data.user);
|
||||
// 按角色跳对应首页
|
||||
const dest =
|
||||
data.user.role === 'admin'
|
||||
? '/config'
|
||||
: data.user.role === 'supervisor'
|
||||
? '/review'
|
||||
: '/tasks/new';
|
||||
router.replace(dest);
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
const action = getErrorAction(apiErr?.code);
|
||||
setError(apiErr?.message || action.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-brand-cream">
|
||||
<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>
|
||||
<p className="text-text-secondary text-sm mt-1">小红书内容工具</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
<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}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
className={INPUT_CLS} aria-required="true" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{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={loading} aria-label="登录"
|
||||
className="w-full bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/app/page.tsx
Normal file
9
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 根路由 / → 重定向到 /dashboard
|
||||
* AuthGuard 会根据登录态再跳到对应首页
|
||||
*/
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
98
frontend/src/app/review/page.tsx
Normal file
98
frontend/src/app/review/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
// 屏5 组长审核台:通过(+5) / 打回必填原因(-3)
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ReviewQueueItem, RejectRequest, PaginatedResponse, ApiError } from '@/types/index';
|
||||
import { RejectModal } from '@/components/review/RejectModal';
|
||||
import { ReviewCard, ReviewSkeleton } from '@/components/review/ReviewCard';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
|
||||
export default function ReviewPage() {
|
||||
const [queue, setQueue] = useState<ReviewQueueItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<number | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
useEffect(() => { loadQueue(); }, []);
|
||||
|
||||
async function loadQueue() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get<PaginatedResponse<ReviewQueueItem>>('/api/v1/review/queue');
|
||||
setQueue(data.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error') {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}
|
||||
|
||||
async function handleApprove(taskId: number) {
|
||||
try {
|
||||
await api.post(`/api/v1/review/${taskId}/approve`);
|
||||
setQueue((q) => q.filter((item) => item.task_id !== taskId));
|
||||
showToast('已通过,进入成品库', 'success');
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
showToast(apiErr?.message || getErrorAction(apiErr?.code).message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(taskId: number, reason: string) {
|
||||
const req: RejectRequest = { reason };
|
||||
await api.post(`/api/v1/review/${taskId}/reject`, req);
|
||||
setQueue((q) => q.filter((item) => item.task_id !== taskId));
|
||||
showToast('已打回,任务回到挑选状态', 'success');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-text-primary">
|
||||
待审笔记{queue.length > 0 && <span className="ml-2 text-sm font-normal text-text-secondary">({queue.length})</span>}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Toast 提示 */}
|
||||
{toast && (
|
||||
<div role="alert" aria-live="assertive"
|
||||
className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl shadow-lg text-sm text-white ${
|
||||
toast.type === 'success' ? 'bg-status-success' : 'bg-status-error'
|
||||
}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<ReviewSkeleton />
|
||||
) : queue.length === 0 ? (
|
||||
<div className="text-center py-16 text-text-tertiary">
|
||||
<span className="text-4xl block mb-2" aria-hidden="true">✓</span>
|
||||
暂无待审笔记
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" aria-label="待审笔记列表">
|
||||
{queue.map((item) => (
|
||||
<ReviewCard key={item.task_id} item={item}
|
||||
onApprove={() => handleApprove(item.task_id)}
|
||||
onReject={() => setRejectTarget(item.task_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-sm text-text-secondary bg-surface-tertiary rounded-xl px-4 py-3">
|
||||
通过的笔记进入成品库,运营可导出文案+图+标签,人工去小红书/达人处发布。
|
||||
</div>
|
||||
|
||||
{rejectTarget !== null && (
|
||||
<RejectModal taskId={rejectTarget}
|
||||
onConfirm={(reason) => handleReject(rejectTarget, reason)}
|
||||
onClose={() => setRejectTarget(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/app/tasks/[id]/confirm/page.tsx
Normal file
91
frontend/src/app/tasks/[id]/confirm/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
// 屏4.5 确认预览:提交审核 / 重新生成(飞轮-1)
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
|
||||
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { useState } 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();
|
||||
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
|
||||
const { textCandidates, imageCandidates } = useGenerationStore();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id));
|
||||
const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
|
||||
|
||||
async function handleSubmitReview() {
|
||||
if (!taskId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/submit-review`);
|
||||
router.push('/review');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
if (!taskId) return;
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
|
||||
router.push(`/tasks/${taskId}/text`);
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<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="grid grid-cols-2 gap-6">
|
||||
{/* 已选文案 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-secondary mb-3">已选文案</h3>
|
||||
{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">
|
||||
<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">
|
||||
{c.content}
|
||||
</p>
|
||||
<span className="text-xs text-text-tertiary mt-1 block">总分 {c.score.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已选图 */}
|
||||
<ConfirmPreviewImages images={selectedImages} />
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<ConfirmActionBar
|
||||
submitting={submitting}
|
||||
regenerating={regenerating}
|
||||
canSubmit={selectedTextIds.length > 0}
|
||||
onSubmit={handleSubmitReview}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/app/tasks/[id]/images/page.tsx
Normal file
86
frontend/src/app/tasks/[id]/images/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
// 屏4 挑图:0/N异步电影 + 平铺选图 + 单批重试(一期=整体重生) + 能离开
|
||||
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 { ImageActionBar } from '@/components/tasks/ImageActionBar';
|
||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||
import { useSse } from '@/hooks/useSse';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function ImageSelectionPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
|
||||
const { selectedImageIds, toggleImageSelection } = useTaskStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
|
||||
const pendingCount = imageTotal - imageDone;
|
||||
const allDone = imageTotal > 0 && pendingCount === 0;
|
||||
|
||||
async function handleRetry(_batchRole: string) {
|
||||
// 一期 regenerate = 整体重生,契约无 batch 字段,不发 body
|
||||
// 二期按批重生时再扩展,_batchRole 目前仅作入参占位(UI 提示用)
|
||||
if (!taskId) return;
|
||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
|
||||
}
|
||||
|
||||
async function handleProceedToConfirm() {
|
||||
if (!taskId || selectedImageIds.length === 0) return;
|
||||
for (const candidateId of selectedImageIds) {
|
||||
await api.post(`/api/v1/tasks/${taskId}/image-candidates/select`, { candidate_id: candidateId });
|
||||
}
|
||||
router.push(`/tasks/${taskId}/confirm`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={3} />
|
||||
{flywheelInjected && (
|
||||
<FlywheelBannerFromSse
|
||||
recentPreference={flywheelInjected.recentPreference}
|
||||
rejectReasons={flywheelInjected.rejectReasons}
|
||||
/>
|
||||
)}
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
||||
{/* 图片网格(谁好谁先冒) */}
|
||||
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
|
||||
{imageCandidates.length === 0
|
||||
? Array.from({ length: 5 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)
|
||||
: imageCandidates.map((c) => (
|
||||
<div key={c.candidate_id}
|
||||
onClick={() => toggleImageSelection(c.candidate_id)}
|
||||
className={`relative rounded-xl overflow-hidden border cursor-pointer transition-all
|
||||
${selectedImageIds.includes(c.candidate_id) ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default hover:border-brand-orange/50'}`}
|
||||
aria-label={`图片 ${c.role},${selectedImageIds.includes(c.candidate_id) ? '已选中' : '未选中'}`}
|
||||
>
|
||||
<img src={c.url} alt={c.role} className="w-full aspect-square object-cover" />
|
||||
<p className="text-xs text-center py-1 text-text-secondary">{c.role}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 失败批次提示 */}
|
||||
{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={`重试第${b.batch}批`}>⟳ 重试批次{b.batch}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imageCandidates.length > 0 && (
|
||||
<ImageActionBar selectedCount={selectedImageIds.length} onProceed={handleProceedToConfirm} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/src/app/tasks/[id]/text/page.tsx
Normal file
94
frontend/src/app/tasks/[id]/text/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
// 屏3 挑文案:SSE实时+五维分+多选+飞轮隐形
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||
import { useSse } from '@/hooks/useSse';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function TextSelectionPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { textCandidates, textDone, textTotal, flywheelInjected } = useGenerationStore();
|
||||
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) setCurrentTask(taskId, 'generating');
|
||||
}, [taskId]);
|
||||
|
||||
async function handleProceedToImages() {
|
||||
if (!taskId || selectedTextIds.length === 0) return;
|
||||
for (const candidateId of selectedTextIds) {
|
||||
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
|
||||
}
|
||||
router.push(`/tasks/${taskId}/images`);
|
||||
}
|
||||
|
||||
const isGenerating = textDone < textTotal || textTotal === 0;
|
||||
const showSkeleton = textCandidates.length === 0 && isGenerating;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={2} />
|
||||
{flywheelInjected && (
|
||||
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 文案卡网格(谁好谁先冒) */}
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{showSkeleton
|
||||
? Array.from({ length: 4 }).map((_, i) => <TextCandidateCardSkeleton key={i} />)
|
||||
: textCandidates.map((c) => (
|
||||
<TextCandidateCard
|
||||
key={c.candidate_id}
|
||||
candidate={c}
|
||||
selected={selectedTextIds.includes(c.candidate_id)}
|
||||
onToggle={toggleTextSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
{textCandidates.length > 0 && (
|
||||
<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> 条
|
||||
</span>
|
||||
<button
|
||||
onClick={handleProceedToImages}
|
||||
disabled={selectedTextIds.length === 0}
|
||||
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"
|
||||
>
|
||||
用这 {selectedTextIds.length} 条去生图 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/app/tasks/new/page.tsx
Normal file
99
frontend/src/app/tasks/new/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
/**
|
||||
* 屏2 开新任务(熟手 /tasks/new)
|
||||
* 使用 NewTaskForm + ConfigPreviewPanel + RecentTasksBar + FlywheelBanner
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { FlywheelBanner } from '@/components/FlywheelBanner';
|
||||
import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel';
|
||||
import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
|
||||
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
|
||||
import { api } from '@/lib/api';
|
||||
import { Product, CreateTaskRequest } from '@/types/index';
|
||||
import { PaginatedResponse } from '@/types/index';
|
||||
import { usePreferenceStore } from '@/stores/preferenceStore';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
import { ApiError } from '@/types/index';
|
||||
|
||||
export default function NewTaskPage() {
|
||||
const router = useRouter();
|
||||
const { context } = usePreferenceStore();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({
|
||||
benchmark_ids: [],
|
||||
theme: '',
|
||||
text_count: 8,
|
||||
image_count: 6,
|
||||
track: 'ai',
|
||||
need_product_image: true,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => { loadProducts(); }, []);
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
|
||||
setProducts(data.items);
|
||||
if (data.items.length > 0) setSelectedProduct(data.items[0]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
|
||||
e.preventDefault();
|
||||
if (!selectedProduct) { setError('请选择产品'); return; }
|
||||
if (!form.theme.trim()) { setError('请填写今天主题'); return; }
|
||||
// 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传
|
||||
if (form.need_product_image && !selectedProduct.image_path) {
|
||||
setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track };
|
||||
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
|
||||
router.push(`/tasks/${data.id}/text`);
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
setError(apiErr?.message || getErrorAction(apiErr?.code).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={1} />
|
||||
{context && <FlywheelBanner context={context} />}
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 主表单区 */}
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6">开新任务</h2>
|
||||
<NewTaskForm
|
||||
products={products}
|
||||
selectedProduct={selectedProduct}
|
||||
onSelectProduct={setSelectedProduct}
|
||||
form={form}
|
||||
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
onSubmitAi={(e) => handleSubmit(e, 'ai')}
|
||||
onSubmitImport={(e) => handleSubmit(e, 'import')}
|
||||
/>
|
||||
<div className="mt-8"><RecentTasksBar /></div>
|
||||
</div>
|
||||
|
||||
{/* 右侧配置预览面板 */}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user