A8多套打包+M4归档+R5多图:存量功能备份

A8 多套交付包(packaging_task.py):
- 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组
- 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容
- task74端到端验:3套各6图,独立agent7项交叉验证全过

M4 归档(tasks.py/exports.py/前端):
- list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1)
- 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM)
- 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner
- response.py加raise_param_error;独立agent验A1/A2/A9通过

R5 产品多图(product_images.py/020迁移/前端):
- product_images表+5端点(上传/列/改场景/设主图/删图)
- 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary
- 前端ProductImageManager多图画廊

R6 账号config拆页(settings/):
- 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin)
- Key只显末4位不显余额(守红线)

核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 17:32:49 +08:00
parent 285791c12f
commit 4bed7425a8
41 changed files with 1211 additions and 236 deletions

View File

@@ -1,30 +1,26 @@
'use client';
/**
* 屏1 配置台(管理 /config
* 产品库 / 标杆笔记 / 违禁词 / API Key 录入
* 🔴 红线key 只录不显余额(不做 Token 用量展示)
* 系统管理 /config(仅管理员
* 标杆笔记 / 违禁词库 —— 平台级配置,写操作后端锁 admin
* 拆分自原 /configB方案运营日常用的 API Key/产品档案已移到 /settings「工作配置」
*/
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';
type TabKey = 'benchmarks' | 'banned_words';
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');
const [activeTab, setActiveTab] = useState<TabKey>('benchmarks');
return (
<div className="p-6 max-w-5xl">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
<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">
@@ -48,10 +44,8 @@ export default function ConfigPage() {
{/* Tab 内容 */}
<div id={`panel-${activeTab}`} role="tabpanel">
{activeTab === 'products' && <ProductsTab />}
{activeTab === 'benchmarks' && <BenchmarksTab />}
{activeTab === 'banned_words' && <BannedWordsTab />}
{activeTab === 'api_keys' && <ApiKeysTab />}
</div>
</div>
);

View File

@@ -0,0 +1,77 @@
'use client';
/**
* HistoryFilters — 历史归档页筛选条(日期区间 + 产品下拉 + 导出按钮)
* 按文件约束从 page.tsx 拆出,避免 page 超行。
*/
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
interface ProductOpt { id: number; name: string }
interface Props {
dateFrom: string;
dateTo: string;
productId: string;
onChange: (patch: { dateFrom?: string; dateTo?: string; productId?: string }) => void;
}
// 通用导出getBlob 下载,带 Authorization header复用 DownloadButton 同款 blob 方案)
async function exportData(path: string, filename: string) {
const blob = await api.getBlob(path);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function HistoryFilters({ dateFrom, dateTo, productId, onChange }: Props) {
const [products, setProducts] = useState<ProductOpt[]>([]);
const [exporting, setExporting] = useState(false);
useEffect(() => {
api.get<{ items: ProductOpt[] }>('/api/v1/products?page_size=100')
.then((d) => setProducts(d.items || []))
.catch(() => setProducts([]));
}, []);
async function handleExport() {
setExporting(true);
try {
await exportData('/api/v1/products/export', 'products.json');
} catch { /* 失败静默,按钮恢复可重试 */ }
finally { setExporting(false); }
}
const inputCls = 'rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-primary focus:border-brand-orange outline-none';
return (
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-sm text-text-secondary">
<input type="date" value={dateFrom} max={dateTo || undefined}
onChange={(e) => onChange({ dateFrom: e.target.value })} className={inputCls} />
</label>
<label className="flex items-center gap-1.5 text-sm text-text-secondary">
<input type="date" value={dateTo} min={dateFrom || undefined}
onChange={(e) => onChange({ dateTo: e.target.value })} className={inputCls} />
</label>
<select value={productId} onChange={(e) => onChange({ productId: e.target.value })} className={inputCls}>
<option value=""></option>
{products.map((p) => <option key={p.id} value={String(p.id)}>{p.name}</option>)}
</select>
{(dateFrom || dateTo || productId) && (
<button onClick={() => onChange({ dateFrom: '', dateTo: '', productId: '' })}
className="text-xs text-text-tertiary hover:text-brand-orange underline"></button>
)}
<button onClick={handleExport} disabled={exporting}
className="ml-auto rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-secondary hover:border-brand-orange disabled:opacity-50">
{exporting ? '导出中…' : '导出产品数据'}
</button>
</div>
);
}

View File

@@ -7,7 +7,7 @@
* window.open 无法附带 Authorization header会 401。
* 改法api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。
*/
import { useState } from 'react';
import { useState, Fragment } from 'react';
import Link from 'next/link';
import { clsx } from 'clsx';
import { TaskListItem } from '@/types';
@@ -76,6 +76,7 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
<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>
@@ -85,8 +86,8 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
</thead>
<tbody>
{tasks.map((task, i) => (
<Fragment key={task.id}>
<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'
@@ -95,6 +96,9 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
<td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate">
{task.theme}
</td>
<td className="px-4 py-3 text-text-secondary">
{task.product_name ?? '-'}
</td>
<td className="px-4 py-3">
<span className={clsx(
'px-2 py-0.5 rounded-full text-xs font-medium',
@@ -128,6 +132,17 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
</div>
</td>
</tr>
{/* 被打回任务:原因独占一行展示,运营见"为何被打回"可重做(R8历史复用) */}
{task.status === 'rejected' && task.reject_reason && (
<tr className={i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'}>
<td colSpan={7} className="px-4 pb-3 pt-0">
<div className="rounded-md bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-600">
<span className="font-medium"></span>{task.reject_reason}
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
@@ -142,6 +157,7 @@ export function HistorySkeleton() {
{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-20" />
<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" />

View File

@@ -10,6 +10,7 @@ import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx';
import { HistoryTable, HistorySkeleton, Pagination } from './components';
import { HistoryFilters } from './HistoryFilters';
const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed'];
const STATUS_LABELS: Record<string, string> = {
@@ -24,14 +25,21 @@ export default function HistoryPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('all');
// M4: 日期区间 + 产品筛选
const [filters, setFilters] = useState({ dateFrom: '', dateTo: '', productId: '' });
const fetchHistory = useCallback(async (p: number, status: string) => {
const fetchHistory = useCallback(async (
p: number, status: string, f: { dateFrom: string; dateTo: string; productId: 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));
if (f.dateFrom) params.append('date_from', f.dateFrom);
if (f.dateTo) params.append('date_to', f.dateTo);
if (f.productId) params.append('product_id', f.productId);
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
setTasks(res.items);
setTotal(res.total);
@@ -43,7 +51,7 @@ export default function HistoryPage() {
}
}, []);
useEffect(() => { fetchHistory(page, statusFilter); }, [page, statusFilter, fetchHistory]);
useEffect(() => { fetchHistory(page, statusFilter, filters); }, [page, statusFilter, filters, fetchHistory]);
const totalPages = Math.ceil(total / PAGE_SIZE);
@@ -74,10 +82,18 @@ export default function HistoryPage() {
))}
</div>
{/* M4: 日期区间 + 产品筛选 + 导出 */}
<HistoryFilters
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
productId={filters.productId}
onChange={(patch) => { setFilters((f) => ({ ...f, ...patch })); setPage(1); }}
/>
{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 className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter, filters)}>
</button>
</div>

View File

@@ -0,0 +1,53 @@
'use client';
/**
* 工作配置 /settings运营+组长+管理员)
* API Key 录入 / 产品档案 —— 各角色日常自助配置
* 🔴 红线key 各人自录自用各算各的;只录不显余额
* 拆分自原 /configB方案admin 专属的标杆/违禁词留在 /config「系统管理」
*/
import { useState } from 'react';
import { ProductsTab } from '@/components/config/ProductsTab';
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
type TabKey = 'api_keys' | 'products';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'api_keys', label: 'API Key' },
{ key: 'products', label: '产品档案' },
];
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<TabKey>('api_keys');
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 === 'api_keys' && <ApiKeysTab />}
{activeTab === 'products' && <ProductsTab />}
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
import { ImportTextModal } from '@/components/tasks/ImportTextModal';
import { api } from '@/lib/api';
import { Product, CreateTaskRequest } from '@/types/index';
import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { usePreferenceStore } from '@/stores/preferenceStore';
import { getErrorAction } from '@/types/errors';
@@ -23,6 +23,7 @@ export default function NewTaskPage() {
const { context } = usePreferenceStore();
const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({
benchmark_ids: [],
theme: '',
@@ -37,6 +38,15 @@ export default function NewTaskPage() {
useEffect(() => { loadProducts(); }, []);
// 选品变化:拉该产品标杆笔记;切产品时清空已选标杆(避免跨产品串用)
useEffect(() => {
if (!selectedProduct) { setBenchmarks([]); return; }
setForm((f) => ({ ...f, benchmark_ids: [] }));
api.get<{ items: Benchmark[] }>(`/api/v1/products/${selectedProduct.id}/benchmarks`)
.then((d) => setBenchmarks(d.items || []))
.catch(() => setBenchmarks([]));
}, [selectedProduct]);
async function loadProducts() {
try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
@@ -125,6 +135,7 @@ export default function NewTaskPage() {
products={products}
selectedProduct={selectedProduct}
onSelectProduct={setSelectedProduct}
benchmarks={benchmarks}
form={form}
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
submitting={submitting}

View File

@@ -2,7 +2,7 @@
/**
* AuthGuard — 路由守卫
* - 未登录 → /login
* - 越权(组长访问 /config,非管理等)→ /dashboard
* - 越权(运营访问 /config 系统管理等)→ /dashboard
* 扒 banana AuthGuard 范式,全新重建
*/
import { useEffect } from 'react';
@@ -13,6 +13,7 @@ import { UserRole } from '@/types/index';
// 路由 → 最低所需角色
const ROUTE_ROLES: Record<string, UserRole[]> = {
'/config': ['admin'],
'/settings': ['admin', 'supervisor', 'operator'],
'/review': ['admin', 'supervisor'],
'/history': ['admin', 'supervisor', 'operator'],
'/tasks/new': ['admin', 'supervisor', 'operator'],

View File

@@ -0,0 +1,154 @@
'use client';
/**
* ProductImageManager — R5 产品多图管理
* 多图上传(带场景类型) + 设主图 + 改场景 + 删图。
* scene 场景类型决定生图时哪张图喂给哪个分镜 role。
*/
import { useRef, useState } from 'react';
import { api } from '@/lib/api';
import { Product, ProductImage, ProductImageScene } from '@/types/dto';
const SCENE_OPTIONS: { value: ProductImageScene; label: string }[] = [
{ value: 'primary', label: '主图/白底' },
{ value: 'scene', label: '使用场景' },
{ value: 'texture', label: '质地特写' },
{ value: 'ingredient', label: '成分/包装' },
{ value: 'model', label: '上脸/使用中' },
];
const sceneLabel = (s: string) =>
SCENE_OPTIONS.find((o) => o.value === s)?.label ?? s;
function toUploadSrc(path: string) {
return `/uploads/${path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`;
}
export function ProductImageManager({
product,
onChanged,
}: {
product: Product;
onChanged: (updated: Product) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [scene, setScene] = useState<ProductImageScene>('primary');
const [uploading, setUploading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState('');
const images = product.images ?? [];
async function refresh() {
const updated = await api.get<Product>(`/api/v1/products/${product.id}`);
onChanged(updated);
}
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) { setError('文件超过 10 MB'); return; }
setError('');
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
form.append('scene', scene);
await api.postForm<Product>(`/api/v1/products/${product.id}/upload-image`, form);
await refresh();
} catch {
setError('上传失败,请重试');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function setPrimary(imageId: number) {
setBusyId(imageId);
try {
await api.put(`/api/v1/products/${product.id}/images/${imageId}/primary`, {});
await refresh();
} finally { setBusyId(null); }
}
async function changeScene(imageId: number, next: string) {
setBusyId(imageId);
try {
await api.put(`/api/v1/products/${product.id}/images/${imageId}/scene`, { scene: next });
await refresh();
} finally { setBusyId(null); }
}
async function removeImage(imageId: number) {
setBusyId(imageId);
try {
await api.delete(`/api/v1/products/${product.id}/images/${imageId}`);
await refresh();
} finally { setBusyId(null); }
}
return (
<div className="mt-2 space-y-2">
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map((im: ProductImage) => (
<div key={im.id}
className="flex items-center gap-2 border border-border-default rounded-lg p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={toUploadSrc(im.path)} alt={`${sceneLabel(im.scene)}`}
className="h-12 w-12 object-cover rounded border border-border-default shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<select value={im.scene} disabled={busyId === im.id}
onChange={(e) => changeScene(im.id, e.target.value)}
aria-label="图片场景类型"
className="w-full text-xs border border-border-default rounded px-1 py-0.5">
{SCENE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="flex items-center gap-2 text-xs">
{im.is_primary ? (
<span className="text-brand-orange font-medium"> </span>
) : (
<button type="button" disabled={busyId === im.id}
onClick={() => setPrimary(im.id)}
className="text-text-secondary hover:text-brand-orange"></button>
)}
<button type="button" disabled={busyId === im.id}
onClick={() => removeImage(im.id)}
className="text-red-500 hover:underline ml-auto"></button>
</div>
</div>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<select value={scene} onChange={(e) => setScene(e.target.value as ProductImageScene)}
aria-label="新图场景类型"
className="text-xs border border-border-default rounded px-2 py-1">
{SCENE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button type="button" onClick={() => inputRef.current?.click()} disabled={uploading}
className="text-xs border border-dashed border-border-default rounded px-3 py-1
text-text-secondary hover:border-brand-orange hover:text-brand-orange
disabled:opacity-50 transition-colors">
{uploading ? '上传中…' : '+ 上传产品图'}
</button>
</div>
{images.length === 0 && (
<p className="text-xs text-text-tertiary leading-relaxed">
/
</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp"
className="hidden" aria-label="选择产品图" onChange={handleFile} />
</div>
);
}

View File

@@ -1,92 +1,16 @@
'use client';
/**
* ProductCard + ProductForm + ProductsSkeleton + ProductImageUpload — 产品档案子组件
* ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件
* 产品多图管理拆到 ProductImageManagerR5
*/
import { useRef, useState } from 'react';
import { useState } from 'react';
import { api } from '@/lib/api';
import { Product, CreateProductRequest } from '@/types/index';
import { ProductImageManager } from './ProductImageManager';
// --- ProductImageUpload ---
export function ProductImageUpload({
product,
onUploaded,
}: {
product: Product;
onUploaded: (updated: Product) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) { setError('文件超过 10 MB'); return; }
setError('');
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
const updated = await api.postForm<Product>(
`/api/v1/products/${product.id}/upload-image`, form
);
onUploaded(updated);
} catch {
setError('上传失败,请重试');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className="mt-2">
{product.image_path ? (
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/uploads/${product.image_path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`}
alt={`${product.name}参考图`}
className="h-12 w-12 object-cover rounded border border-border-default"
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-xs text-brand-orange hover:underline"
>
</button>
</div>
) : (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="text-xs border border-dashed border-border-default rounded px-3 py-1
text-text-secondary hover:border-brand-orange hover:text-brand-orange
disabled:opacity-50 transition-colors"
>
{uploading ? '上传中…' : '+ 上传产品参考图'}
</button>
<p className="text-xs text-text-tertiary mt-1 leading-relaxed">
/
</p>
</div>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
aria-label="选择产品参考图"
onChange={handleFile}
/>
</div>
);
}
// ProductImageUpload 旧单图组件已被 ProductImageManager多图+场景)取代。
// 保留导出名向后兼容引用方。
export { ProductImageManager as ProductImageUpload };
export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) {
const [current, setCurrent] = useState<Product>(product);
@@ -109,9 +33,9 @@ export function ProductCard({ product, onUpdated }: { product: Product; onUpdate
</span>
))}
</div>
<ProductImageUpload
<ProductImageManager
product={current}
onUploaded={(updated) => { setCurrent(updated); onUpdated(); }}
onChanged={(updated) => { setCurrent(updated); onUpdated(); }}
/>
</div>
);
@@ -124,14 +48,18 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc
custom_prompt: null, banned_word_ids: [],
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!form.name) return;
setSaving(true);
if (!form.name.trim()) { setError('请填写产品名称'); return; }
setSaving(true); setError('');
try {
await api.post('/api/v1/products', form);
onSaved();
} catch (err) {
const msg = (err as { message?: string })?.message;
setError(msg || '保存失败,请重试');
} finally {
setSaving(false);
}
@@ -158,6 +86,7 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc
/>
</div>
</div>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={onCancel}
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">

View File

@@ -2,7 +2,7 @@
/**
* Sidebar — 左侧8项导航
* 按 PRD-前端§2 全局布局
* 角色守卫:/review 只组长+管理员;/config 只管理员
* 角色守卫:/review 只组长+管理员;/settings 全员;/config 只管理员
*/
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -26,7 +26,8 @@ const NAV_ITEMS: NavItem[] = [
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
{ label: '历史归档', href: '/history', icon: '🗂️' },
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' },
{ label: '工作配置', href: '/settings', icon: '🔧' },
{ label: '系统管理', href: '/config', roles: ['admin'], icon: '⚙️' },
];
export function Sidebar() {

View File

@@ -3,13 +3,14 @@
* NewTaskForm — 开任务表单屏2主体
* 选产品 + 今天主题 + 数量 + 双轨入口
*/
import { Product, CreateTaskRequest } from '@/types/index';
import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
import { CountStepper } from './CountStepper';
interface NewTaskFormProps {
products: Product[];
selectedProduct: Product | null;
onSelectProduct: (p: Product | null) => void;
benchmarks: Benchmark[];
form: Omit<CreateTaskRequest, 'product_id'>;
onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void;
submitting: boolean;
@@ -19,7 +20,7 @@ interface NewTaskFormProps {
}
export function NewTaskForm({
products, selectedProduct, onSelectProduct,
products, selectedProduct, onSelectProduct, benchmarks,
form, onFormChange, submitting, error,
onSubmitAi, onSubmitImport,
}: NewTaskFormProps) {
@@ -28,6 +29,15 @@ export function NewTaskForm({
onFormChange({ [field]: Math.max(1, Math.min(max, form[field] + delta)) });
}
// 只允许选已分析完(analyze_status=done)的标杆——未分析的无 features 注入无意义
const usableBenchmarks = benchmarks.filter((b) => b.analyze_status === 'done');
function toggleBenchmark(id: number) {
const cur = form.benchmark_ids || [];
onFormChange({
benchmark_ids: cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id],
});
}
// 产品参考图状态image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问
const rawPath = selectedProduct?.image_path || '';
const hasProductImage = !!rawPath;
@@ -93,6 +103,30 @@ export function NewTaskForm({
<p className="text-xs text-text-tertiary mt-1">{form.theme.length}/100</p>
</div>
{/* 对标爆款可选多选选中标杆把其8维配方注入文案生成 */}
{usableBenchmarks.length > 0 && (
<div className="bg-surface-secondary rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-text-primary">
<span className="text-xs text-text-tertiary ml-1"></span>
</p>
<div className="space-y-1.5 max-h-40 overflow-auto">
{usableBenchmarks.map((b) => (
<label key={b.id} className="flex items-start gap-2 cursor-pointer text-sm">
<input type="checkbox"
checked={(form.benchmark_ids || []).includes(b.id)}
onChange={() => toggleBenchmark(b.id)}
className="w-4 h-4 mt-0.5 accent-brand-orange"
aria-label={`选用标杆 ${b.id}`} />
<span className="text-text-secondary line-clamp-2">
{b.highlights || `标杆 #${b.id}`}
</span>
</label>
))}
</div>
<p className="text-xs text-text-tertiary"> {(form.benchmark_ids || []).length} 3 </p>
</div>
)}
{/* 数量设定(不写死) */}
<div className="flex gap-6">
<CountStepper label="生几条文案" value={form.text_count}

View File

@@ -8,6 +8,7 @@ export type TaskStatus =
export interface TaskListItem {
id: number;
product_id: number;
product_name?: string | null; // M4: 历史页"产品"列,list_tasks 批量 JOIN 填充
theme: string;
status: TaskStatus;
created_at: string;

View File

@@ -37,6 +37,16 @@ export interface ApiKey {
export interface CreateApiKeyRequest { provider: string; api_key: string; }
// 产品档案
export type ProductImageScene = 'primary' | 'scene' | 'texture' | 'ingredient' | 'model';
export interface ProductImage {
id: number;
path: string;
scene: ProductImageScene;
is_primary: boolean;
sort_order: number;
}
export interface Product {
id: number;
name: string;
@@ -47,7 +57,8 @@ export interface Product {
text_angles: string[];
custom_prompt: string | null;
banned_word_ids: number[];
image_path: string | null; // 产品参考图路径(铁律:生图带产品图)
image_path: string | null; // 产品参考图路径(铁律:生图带产品图,向后兼容=主图
images?: ProductImage[]; // R5多图每张带 scene 场景类型
}
export interface CreateProductRequest {
@@ -67,6 +78,8 @@ export interface Benchmark {
screenshot_url: string;
highlights: string;
link_url: string | null;
analyze_status?: 'pending' | 'analyzing' | 'done' | 'failed';
features?: Record<string, unknown> | null;
}
export interface CreateBenchmarkRequest {

View File

@@ -9,6 +9,7 @@ export type {
SwitchWorkspaceRequest, SwitchWorkspaceResponse,
ApiKey, CreateApiKeyRequest,
Product, CreateProductRequest,
ProductImage, ProductImageScene,
Benchmark, CreateBenchmarkRequest,
BannedWordLevel, BannedWord, CreateBannedWordRequest,
} from './dto';