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

@@ -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}