上线版: 产品表单统一+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:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -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>
{/* 参考强度 */}

View File

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

View 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>
);
}