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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user