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