-

+ {b.screenshot_url ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
})
+ ) : (
+
+ 未上传截图
+
+ )}
-
{b.highlights}
+
+
+ {b.analyze_status === 'done' ? '分析完成' : b.analyze_status === 'failed' ? '分析失败' : '待分析'}
+
+ {b.analysis_source === 'fallback' && (
+ 兜底结果
+ )}
+
+ {b.analysis_warning && (
+
+ {b.analysis_warning}
+
+ )}
+ {b.highlights && (
+
{b.highlights}
+ )}
{b.link_url && (
查看原帖
@@ -80,7 +114,7 @@ export function BenchmarksTab() {
))}
{benchmarks.length === 0 && (
- 暂无标杆笔记,点击"传成品包"上传截图
+ 暂无标杆笔记,点击「传成品包」上传截图
)}
diff --git a/frontend/src/components/config/CreateUserModal.tsx b/frontend/src/components/config/CreateUserModal.tsx
new file mode 100644
index 0000000..947ad9c
--- /dev/null
+++ b/frontend/src/components/config/CreateUserModal.tsx
@@ -0,0 +1,88 @@
+'use client';
+/**
+ * CreateUserModal — 管理员新建账号弹窗
+ * 用户名/邮箱/角色/初始密码;建成后该账号首登强制改密。
+ */
+import { useState } from 'react';
+import { api } from '@/lib/api';
+import { ApiError } from '@/types';
+
+const INPUT_CLS = 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange transition-colors';
+
+const ROLES = [
+ { value: 'operator', label: '运营(日常出稿)' },
+ { value: 'supervisor', label: '组长(可审核)' },
+ { value: 'admin', label: '管理员(含账号管理)' },
+];
+
+export function CreateUserModal({ onClose, onCreated }: {
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [role, setRole] = useState('operator');
+ const [initPassword, setInitPassword] = useState('');
+ const [error, setError] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (initPassword.length < 8) {
+ setError('初始密码至少 8 位');
+ return;
+ }
+ setSaving(true);
+ setError('');
+ try {
+ await api.post('/api/v1/users', {
+ username, email, role, init_password: initPassword,
+ });
+ onCreated();
+ } catch (err) {
+ setError((err as ApiError)?.message || '创建失败');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
新建账号
+
+
+
+ );
+}
diff --git a/frontend/src/components/config/ProductFormFull.tsx b/frontend/src/components/config/ProductFormFull.tsx
new file mode 100644
index 0000000..6dad0b2
--- /dev/null
+++ b/frontend/src/components/config/ProductFormFull.tsx
@@ -0,0 +1,127 @@
+'use client';
+/**
+ * ProductFormFull — 统一产品档案表单(新建 + 编辑共用,倩倩姐2026-06-26拍板)
+ * 字段:产品名(必填)/品类/品牌词/风格调性/目标人群/卖点(多条,一行一条)。
+ * 这些字段全部喂进文案+生图 prompt(_text_prompt.py / storyboard.py),填得越细生成越贴合。
+ * product 有值=编辑(PUT /products/{id});无值=新建(POST /products)。
+ * 图片管理不在此组件(由 ProductCard / ProductEditPanel 各自的 ProductImageManager 负责)。
+ * text_angles / custom_prompt 偏技术暂不暴露,编辑时原样保留不清空。
+ */
+import { useState } from 'react';
+import { api } from '@/lib/api';
+import { Product, CreateProductRequest } from '@/types/index';
+
+export function ProductFormFull({
+ product,
+ onSaved,
+ onCancel,
+}: {
+ product?: Product;
+ onSaved: (p: Product) => void;
+ onCancel: () => void;
+}) {
+ const isEdit = !!product;
+ const [name, setName] = useState(product?.name ?? '');
+ const [category, setCategory] = useState(product?.category ?? '');
+ const [brandKeyword, setBrandKeyword] = useState(product?.brand_keyword ?? '');
+ const [styleTone, setStyleTone] = useState(product?.style_tone ?? '');
+ const [targetAudience, setTargetAudience] = useState(product?.target_audience ?? '');
+ const [sellingPointsText, setSellingPointsText] = useState(
+ (product?.selling_points ?? []).join('\n'),
+ );
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault();
+ if (!name.trim()) { setError('产品名不能为空'); return; }
+ setSaving(true); setError('');
+ const payload: CreateProductRequest = {
+ name: name.trim(),
+ category: category.trim(),
+ source: product?.source ?? 'custom',
+ selling_points: sellingPointsText.split('\n').map((s) => s.trim()).filter(Boolean),
+ style_tone: styleTone.trim(),
+ text_angles: product?.text_angles ?? [],
+ custom_prompt: product?.custom_prompt ?? null,
+ banned_word_ids: product?.banned_word_ids ?? [],
+ brand_keyword: brandKeyword.trim() || null,
+ target_audience: targetAudience.trim() || null,
+ };
+ try {
+ const saved = isEdit
+ ? await api.put
(`/api/v1/products/${product!.id}`, payload)
+ : await api.post('/api/v1/products', payload);
+ onSaved(saved);
+ } catch (err) {
+ setError((err as { message?: string })?.message || '保存失败,请重试');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const inputCls =
+ 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange';
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/config/ProductImageManager.tsx b/frontend/src/components/config/ProductImageManager.tsx
index 23313d0..315acef 100644
--- a/frontend/src/components/config/ProductImageManager.tsx
+++ b/frontend/src/components/config/ProductImageManager.tsx
@@ -94,9 +94,17 @@ export function ProductImageManager({
{images.map((im: ProductImage) => (
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
})
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
})
+ {/* 图上叠当前场景徽章:放大缩略图+一眼可辨,防止瓶身/质地图标反 */}
+
+ {sceneLabel(im.scene)}
+
+
- {images.length === 0 && (
+ {images.length === 0 ? (
首张必须含产品主体(瓶身/包装本体)当主图,生图以此为锚点保证产品一致。
质地图、上脸图等设对应场景类型,生图会按分镜自动选用。
+ ) : (
+
+ ⚠️ 核对每张图下方场景:「主图/白底」必须是产品瓶身本体(生图的产品锚点),
+ 质地/上脸图请勿误标成主图,否则生图产品会走样。
+
)}
{error &&
{error}
}
void }) {
const [current, setCurrent] = useState
(product);
+ const role = useAuthStore((s) => s.role);
+ const [confirming, setConfirming] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleDelete() {
+ setDeleting(true); setError('');
+ try {
+ await api.delete(`/api/v1/products/${current.id}`);
+ setConfirming(false);
+ onUpdated();
+ } catch (err) {
+ setError((err as { message?: string })?.message || '删除失败,请重试');
+ } finally {
+ setDeleting(false);
+ }
+ }
+
+ if (editing) {
+ return (
+ { setCurrent(p); setEditing(false); onUpdated(); }}
+ onCancel={() => setEditing(false)}
+ />
+ );
+ }
return (
{current.name}
-
- {current.category}
-
+
+
+
+ {current.category}
+
+
{current.selling_points.join(' · ')}
@@ -37,70 +73,37 @@ export function ProductCard({ product, onUpdated }: { product: Product; onUpdate
product={current}
onChanged={(updated) => { setCurrent(updated); onUpdated(); }}
/>
+ {role === 'admin' && (
+
+ {!confirming ? (
+
+ ) : (
+
+
+ 确定删除产品「{current.name}」吗?删除后不可恢复(已有历史任务的产品将停用并保留记录)。
+
+ {error &&
{error}
}
+
+
+
+
+
+ )}
+
+ )}
);
}
-export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCancel: () => void }) {
- const [form, setForm] = useState({
- name: '', category: '', source: 'custom',
- selling_points: [], style_tone: '', text_angles: [],
- 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.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);
- }
- }
-
- return (
-
- );
-}
-
export function ProductsSkeleton() {
return (
diff --git a/frontend/src/components/config/ProductsTab.tsx b/frontend/src/components/config/ProductsTab.tsx
index aa029c4..dfb4baf 100644
--- a/frontend/src/components/config/ProductsTab.tsx
+++ b/frontend/src/components/config/ProductsTab.tsx
@@ -7,7 +7,8 @@ import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Product } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
-import { ProductCard, ProductForm, ProductsSkeleton } from './ProductSubComponents';
+import { ProductCard, ProductsSkeleton } from './ProductSubComponents';
+import { ProductFormFull } from './ProductFormFull';
export function ProductsTab() {
const [products, setProducts] = useState
([]);
@@ -70,7 +71,7 @@ export function ProductsTab() {
)}
{showForm && (
- { setShowForm(false); loadProducts(); }}
onCancel={() => setShowForm(false)}
/>
diff --git a/frontend/src/components/config/UserManageTab.tsx b/frontend/src/components/config/UserManageTab.tsx
new file mode 100644
index 0000000..6a005d9
--- /dev/null
+++ b/frontend/src/components/config/UserManageTab.tsx
@@ -0,0 +1,168 @@
+'use client';
+/**
+ * UserManageTab — 系统管理 → 账号管理(仅 admin)
+ * 列出本 workspace 成员,建号,启用/禁用(软删除,可恢复)。
+ * 倩倩姐2026-06-30:禁用=软删除随时恢复,不物理删。
+ */
+import { useEffect, useState, useCallback } from 'react';
+import { api } from '@/lib/api';
+import { ApiError } from '@/types';
+import { CreateUserModal } from './CreateUserModal';
+
+interface WsUser {
+ id: number;
+ username: string;
+ email: string;
+ role: string;
+ is_active: boolean;
+ must_change_password: boolean;
+ last_login_at: string | null;
+}
+
+const ROLE_LABELS: Record = {
+ admin: '管理员', supervisor: '组长', operator: '运营',
+};
+
+function UserTable({ users, onToggle, onDelete, roleLabels }: {
+ users: WsUser[];
+ onToggle: (u: WsUser) => void;
+ onDelete: (u: WsUser) => void;
+ roleLabels: Record;
+}) {
+ if (users.length === 0) return 暂无账号。
;
+ return (
+
+
+
+ | 用户名 |
+ 角色 |
+ 状态 |
+ 最后登录 |
+ 操作 |
+
+
+
+ {users.map((u) => (
+
+ |
+ {u.username}
+ {u.must_change_password && 待改密}
+ |
+ {roleLabels[u.role] || u.role} |
+
+ {u.is_active
+ ? 启用
+ : 已禁用}
+ |
+
+ {u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '从未登录'}
+ |
+
+
+
+ |
+
+ ))}
+
+
+ );
+}
+
+export function UserManageTab() {
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [showCreate, setShowCreate] = useState(false);
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const res = await api.get('/api/v1/users');
+ setUsers(res || []);
+ } catch (err) {
+ setError((err as ApiError)?.message || '加载失败');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ async function toggleActive(u: WsUser) {
+ try {
+ await api.post(`/api/v1/users/${u.id}/active`, { is_active: !u.is_active });
+ await load();
+ } catch (err) {
+ setError((err as ApiError)?.message || '操作失败');
+ }
+ }
+
+ async function confirmDelete() {
+ if (!pendingDelete) return;
+ try {
+ await api.delete(`/api/v1/users/${pendingDelete.id}`);
+ setPendingDelete(null);
+ await load();
+ } catch (err) {
+ setError((err as ApiError)?.message || '删除失败');
+ setPendingDelete(null);
+ }
+ }
+
+ return (
+
+
+
管理本工作区的登录账号,新账号首次登录须改密。
+
+
+ {error &&
{error}
}
+ {loading ? (
+
加载中…
+ ) : (
+
+ )}
+ {pendingDelete && (
+
+
+
删除账号
+
+ 确定永久删除账号 {pendingDelete.username} 吗?
+
+
删除后不可恢复,与「禁用」不同。如只是暂停使用请用禁用。
+
+
+
+
+
+
+ )}
+ {showCreate && (
+
setShowCreate(false)}
+ onCreated={() => { setShowCreate(false); load(); }}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/components/fission/FissionLauncher.tsx b/frontend/src/components/fission/FissionLauncher.tsx
index d44f513..019c95f 100644
--- a/frontend/src/components/fission/FissionLauncher.tsx
+++ b/frontend/src/components/fission/FissionLauncher.tsx
@@ -8,6 +8,7 @@ import { api } from '@/lib/api';
import { TaskListItem } from '@/types';
import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx';
+import { FissionSourceCard } from './FissionSourceCard';
interface Props {
sources: TaskListItem[];
@@ -48,18 +49,22 @@ export function FissionLauncher({ sources, onLaunched }: Props) {
{/* 选源爆款 */}
-
-
{/* 参考强度 */}
diff --git a/frontend/src/components/fission/FissionProgress.tsx b/frontend/src/components/fission/FissionProgress.tsx
index 6475d60..e7feddc 100644
--- a/frontend/src/components/fission/FissionProgress.tsx
+++ b/frontend/src/components/fission/FissionProgress.tsx
@@ -1,17 +1,18 @@
'use client';
/**
- * FissionProgress — 裂变进度(轮询 GET /fission/{id} 聚合 x/N 完成)
- * 新架构:一次 LLM 出 N 套完整笔记包落 FissionNote,完成后进独立展示页看 N 套。
+ * FissionProgress — 裂变进度(A3)
+ * 保持轮询机制,用轮询返回的 done/total + 起始时间戳推算阶段和 ETA
*/
-import { useEffect, useState, useCallback } from 'react';
+import { useEffect, useState, useCallback, useRef } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';
+import { StageLoadingPanel } from '@/components/common/StageLoadingPanel';
interface FissionStatus {
fission_id: number;
reference_level: string;
fanout_count: number;
- status: 'generating' | 'completed' | 'failed';
+ status: 'generating' | 'completed' | 'completed_with_fallback' | 'completed_fb' | 'failed';
progress: { done: number; failed: number; total: number };
}
@@ -20,9 +21,36 @@ interface Props {
onReset: () => void;
}
+const FISSION_STAGES = [
+ { label: '解析爆款结构' },
+ { label: '提炼卖点与人群' },
+ { label: '撰写标题正文标签' },
+ { label: '规划叙事链路和图文分镜' },
+ { label: '生成图片与质检' },
+ { label: '整理完整交付包' },
+];
+
+/** 完成判定:兼容新短码 completed_fb 与历史旧值 completed_with_fallback */
+const isFissionDone = (s: string) =>
+ s === 'completed' || s === 'completed_fb' || s === 'completed_with_fallback';
+/** 是否含品类兜底草稿(需人工复核提示) */
+const isFissionFallback = (s: string) =>
+ s === 'completed_fb' || s === 'completed_with_fallback';
+
+/** 根据 done/total 推算裂变阶段 */
+function calcFissionStageIdx(done: number, total: number, status: string): number {
+ if (isFissionDone(status)) return FISSION_STAGES.length;
+ if (done === 0) return 1; // 提炼卖点
+ if (done < total * 0.3) return 2; // 撰写文案
+ if (done < total * 0.6) return 3; // 规划叙事
+ if (done < total) return 4; // 生成图片
+ return 5; // 整理交付包
+}
+
export function FissionProgress({ fissionId, onReset }: Props) {
const [data, setData] = useState
(null);
const [error, setError] = useState(null);
+ const startedAtRef = useRef(Date.now());
const poll = useCallback(async () => {
try {
@@ -36,6 +64,7 @@ export function FissionProgress({ fissionId, onReset }: Props) {
}, [fissionId]);
useEffect(() => {
+ startedAtRef.current = Date.now();
let timer: ReturnType;
let stopped = false;
const tick = async () => {
@@ -58,7 +87,8 @@ export function FissionProgress({ fissionId, onReset }: Props) {
if (!data) return 启动裂变…
;
const { progress, status } = data;
- const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
+ const activeIdx = calcFissionStageIdx(progress.done, progress.total, status);
+ const allDone = isFissionDone(status);
return (
@@ -66,28 +96,31 @@ export function FissionProgress({ fissionId, onReset }: Props) {
裂变 #{data.fission_id} · {data.fanout_count} 套
-
- {progress.done}/{progress.total || data.fanout_count} 完成
- {progress.failed > 0 && · {progress.failed} 失败}
-
+ {progress.failed > 0 && (
+ {progress.failed} 失败
+ )}
-
+
- {status === 'generating' && (
- 正在一次性生成 {data.fanout_count} 套差异化笔记包,请稍候…
- )}
{status === 'failed' && (
这批裂变全部生成失败,可点下方「再裂变一个」重试,或换一个源爆款。
)}
- {status === 'completed' && (
+ {isFissionFallback(status) && (
+
+ 本次部分内容使用了品类兜底草稿,请人工复核标题、正文和分镜后再交付。
+
+ )}
+ {allDone && (
)}
-
- {item.selected_image && (
+ {sets.length === 0 && item.selected_image && (
)}
+ {sets.length > 0 && (
+
+ {sets.map((set) => (
+
+
+ 套{set.strategy}
+ {set.selected_images.length} 张图
+
+ {set.selected_text && (
+ <>
+
+ {set.selected_text.content}
+
+ {set.selected_text.score && (
+
+ )}
+ >
+ )}
+ {set.selected_images.length > 0 && (
+
+ {set.selected_images.filter(Boolean).map((img) => (
+

+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
{/* 操作栏 — 飞轮最强信号 */}
void; // 非管理员(运营/组长):就地编辑当前产品,不离开文案创作页
}
-export function ConfigPreviewPanel({ product }: ConfigPreviewPanelProps) {
+export function ConfigPreviewPanel({ product, onEditInline }: ConfigPreviewPanelProps) {
+ const { role } = useAuthStore();
return (