上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段); 修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品 - dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开; settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch - 新增用户管理(users API/admin service/改密页) + alembic 022/023/024 - 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example - A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* 选源爆款 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">源爆款笔记</label>
|
||||
<select
|
||||
value={sourceId ?? ''}
|
||||
onChange={(e) => setSourceId(Number(e.target.value))}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
|
||||
>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
源爆款笔记
|
||||
<span className="text-xs text-text-tertiary font-normal ml-2">
|
||||
从你「已通过 / 已归档」的爆款里挑一条,裂变会借它的结构再产 N 套
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-72 overflow-auto pr-1">
|
||||
{sources.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
#{t.id} · {t.theme || '(无主题)'}
|
||||
</option>
|
||||
<FissionSourceCard
|
||||
key={t.id}
|
||||
task={t}
|
||||
selected={sourceId === t.id}
|
||||
onSelect={() => setSourceId(t.id)}
|
||||
/>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参考强度 */}
|
||||
|
||||
@@ -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<FissionStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const startedAtRef = useRef<number>(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<typeof setTimeout>;
|
||||
let stopped = false;
|
||||
const tick = async () => {
|
||||
@@ -58,7 +87,8 @@ export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
if (!data) return <div className="py-12 text-center text-text-tertiary text-sm">启动裂变…</div>;
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -66,28 +96,31 @@ export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
<h2 className="text-base font-semibold text-text-primary">
|
||||
裂变 #{data.fission_id} · {data.fanout_count} 套
|
||||
</h2>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{progress.done}/{progress.total || data.fanout_count} 完成
|
||||
{progress.failed > 0 && <span className="text-red-500"> · {progress.failed} 失败</span>}
|
||||
</span>
|
||||
{progress.failed > 0 && (
|
||||
<span className="text-sm text-red-500">{progress.failed} 失败</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-2 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-orange transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<StageLoadingPanel
|
||||
title={allDone ? `已生成 ${data.fanout_count} 套笔记包` : `正在裂变 ${progress.done}/${progress.total || data.fanout_count}`}
|
||||
stages={FISSION_STAGES}
|
||||
activeIdx={activeIdx}
|
||||
targetSec={95}
|
||||
startedAt={startedAtRef.current}
|
||||
className=""
|
||||
/>
|
||||
|
||||
{status === 'generating' && (
|
||||
<p className="text-sm text-text-tertiary">正在一次性生成 {data.fanout_count} 套差异化笔记包,请稍候…</p>
|
||||
)}
|
||||
{status === 'failed' && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-600">
|
||||
这批裂变全部生成失败,可点下方「再裂变一个」重试,或换一个源爆款。
|
||||
</div>
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
{isFissionFallback(status) && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-700">
|
||||
本次部分内容使用了品类兜底草稿,请人工复核标题、正文和分镜后再交付。
|
||||
</div>
|
||||
)}
|
||||
{allDone && (
|
||||
<Link
|
||||
href={`/fission/${data.fission_id}/notes`}
|
||||
className="self-start rounded-lg bg-brand-orange text-white px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
@@ -95,7 +128,6 @@ export function FissionProgress({ fissionId, onReset }: Props) {
|
||||
查看 {data.fanout_count} 套笔记包 →
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
|
||||
91
frontend/src/components/fission/FissionSourceCard.tsx
Normal file
91
frontend/src/components/fission/FissionSourceCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
/**
|
||||
* FissionSourceCard — 裂变源爆款卡片(#8 源爆款看不清/无图)
|
||||
* 富化展示:封面缩略图(懒拉首张已选图) + 主题 + 产品 + 文/图条数 + 状态徽章。
|
||||
* 替代原来干巴巴的下拉框,让运营看清"在拿哪条爆款去裂变"。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { TaskListItem } from '@/types';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
function toImgSrc(url: string) {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//.test(url)) return url;
|
||||
return '/uploads/' + url.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '');
|
||||
}
|
||||
|
||||
export function FissionSourceCard({
|
||||
task, selected, onSelect,
|
||||
}: {
|
||||
task: TaskListItem;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [cover, setCover] = useState<string>('');
|
||||
const [loadingCover, setLoadingCover] = useState(true);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
// 卡片进视口才拉封面,避免一次性 50 条全并发 GET 造成请求风暴(交叉验证#5)
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting) { setInView(true); io.disconnect(); }
|
||||
}, { rootMargin: '100px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
// 懒拉封面:取该任务首张已选图(没有则第一张)。列表接口无封面字段,按需单查。
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
let alive = true;
|
||||
api.get<{ image_candidates?: { url: string; is_selected?: boolean }[] }>(`/api/v1/tasks/${task.id}`)
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
const imgs = d.image_candidates || [];
|
||||
const pick = imgs.find((i) => i.is_selected) || imgs[0];
|
||||
setCover(pick ? toImgSrc(pick.url) : '');
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (alive) setLoadingCover(false); });
|
||||
return () => { alive = false; };
|
||||
}, [inView, task.id]);
|
||||
|
||||
return (
|
||||
<button ref={ref} type="button" onClick={onSelect}
|
||||
className={clsx(
|
||||
'flex gap-3 w-full text-left border rounded-lg p-3 transition-colors',
|
||||
selected ? 'border-brand-orange bg-brand-orange-light' : 'border-border-default hover:border-brand-orange',
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className="w-16 h-16 rounded-md bg-surface-tertiary shrink-0 overflow-hidden flex items-center justify-center">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs text-text-tertiary">{loadingCover ? '…' : '无图'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text-primary truncate">
|
||||
{task.theme || '(无主题)'}
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary mt-0.5 truncate">
|
||||
{task.product_name || `产品#${task.product_id}`} · #{task.id}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-xs text-text-secondary">{task.text_count} 文 · {task.image_count} 图</span>
|
||||
<span className={clsx(
|
||||
'text-[10px] px-1.5 py-0.5 rounded',
|
||||
task.status === 'approved' ? 'bg-green-50 text-green-600' : 'bg-surface-tertiary text-text-tertiary',
|
||||
)}>
|
||||
{task.status === 'approved' ? '已通过' : '已归档'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user