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:
@@ -1,30 +1,26 @@
|
||||
'use client';
|
||||
/**
|
||||
* 屏1 配置台(管理员 /config)
|
||||
* 产品库 / 标杆笔记 / 违禁词 / API Key 录入
|
||||
* 🔴 红线:key 只录不显余额(不做 Token 用量展示)
|
||||
* 系统管理 /config(仅管理员)
|
||||
* 标杆笔记 / 违禁词库 —— 平台级配置,写操作后端锁 admin
|
||||
* 拆分自原 /config(B方案):运营日常用的 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>
|
||||
);
|
||||
|
||||
77
frontend/src/app/history/HistoryFilters.tsx
Normal file
77
frontend/src/app/history/HistoryFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
frontend/src/app/settings/page.tsx
Normal file
53
frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
/**
|
||||
* 工作配置 /settings(运营+组长+管理员)
|
||||
* API Key 录入 / 产品档案 —— 各角色日常自助配置
|
||||
* 🔴 红线:key 各人自录自用各算各的;只录不显余额
|
||||
* 拆分自原 /config(B方案):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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
154
frontend/src/components/config/ProductImageManager.tsx
Normal file
154
frontend/src/components/config/ProductImageManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +1,16 @@
|
||||
'use client';
|
||||
/**
|
||||
* ProductCard + ProductForm + ProductsSkeleton + ProductImageUpload — 产品档案子组件
|
||||
* ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件
|
||||
* 产品多图管理拆到 ProductImageManager(R5)。
|
||||
*/
|
||||
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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
SwitchWorkspaceRequest, SwitchWorkspaceResponse,
|
||||
ApiKey, CreateApiKeyRequest,
|
||||
Product, CreateProductRequest,
|
||||
ProductImage, ProductImageScene,
|
||||
Benchmark, CreateBenchmarkRequest,
|
||||
BannedWordLevel, BannedWord, CreateBannedWordRequest,
|
||||
} from './dto';
|
||||
|
||||
Reference in New Issue
Block a user