Files
beige/frontend/src/app/tasks/new/page.tsx
yangqianqian df1856d793 上线版: 产品表单统一+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>
2026-06-30 18:08:13 +08:00

178 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
/**
* 屏2 开新任务(熟手 /tasks/new
* 使用 NewTaskForm + ConfigPreviewPanel + RecentTasksBar + FlywheelBanner
*/
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBanner } from '@/components/FlywheelBanner';
import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel';
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, Benchmark } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { usePreferenceStore } from '@/stores/preferenceStore';
import { getErrorAction } from '@/types/errors';
import { ApiError } from '@/types/index';
export default function NewTaskPage() {
const router = useRouter();
const { context, fetchContextByProduct } = 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: '',
text_count: 8,
image_count: 6,
track: 'ai',
need_product_image: true,
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState(false); // 提升:供右侧「编辑配置」(运营/组长)触发就地编辑
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]);
// 选品变化:拉该产品飞轮偏好上下文,驱动顶部 FlywheelBanner
useEffect(() => {
if (!selectedProduct) return;
fetchContextByProduct(selectedProduct.id);
}, [selectedProduct?.id]);
async function loadProducts() {
try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
setProducts(data.items);
if (data.items.length > 0) setSelectedProduct(data.items[0]);
} catch {
// ignore
}
}
// #1 内联建品把新产品并入列表顶部并选中QuickProductCreate 已含上传产品图)
function handleProductCreated(p: Product) {
setProducts((list) => [p, ...list.filter((x) => x.id !== p.id)]);
setSelectedProduct(p);
}
function validate(): boolean {
if (!selectedProduct) { setError('请选择产品'); return false; }
if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
// 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传
if (form.need_product_image && !selectedProduct.image_path) {
setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。');
return false;
}
setError('');
return true;
}
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
e.preventDefault();
if (!validate() || !selectedProduct) return;
setSubmitting(true);
try {
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track };
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
router.push(`/tasks/${data.id}/text`);
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || getErrorAction(apiErr?.code).message);
} finally {
setSubmitting(false);
}
}
// 轨B先校验产品/主题,通过后开弹窗让用户贴文案
function openImport(e: React.FormEvent) {
e.preventDefault();
if (validate()) setImportOpen(true);
}
// 弹窗确认:建 import 任务 → 逐条导入候选池 → 跳 text 页
async function handleImportConfirm(contents: string[]) {
if (!selectedProduct) return;
setSubmitting(true);
try {
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track: 'import' };
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
// 任务已建。逐条导入;某条失败也不丢任务——跳进已建任务的 text 页,
// 半成品文案在那里可见可续,避免任务 ID 丢失变成找不回的孤儿任务。
let imported = 0;
try {
for (const content of contents) {
await api.post(`/api/v1/tasks/${data.id}/import-text`, { content });
imported += 1;
}
} catch (impErr) {
const ae = impErr as ApiError;
setError(`已导入 ${imported}/${contents.length} 条后中断(${ae?.message || '导入失败'}),已为你保留任务 #${data.id},可在文案页补齐。`);
}
setImportOpen(false);
router.push(`/tasks/${data.id}/text`);
} catch (err) {
// 建任务本身失败(还没有任务 ID
const apiErr = err as ApiError;
setError(apiErr?.message || getErrorAction(apiErr?.code).message);
setImportOpen(false);
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={1} />
{context && <FlywheelBanner context={context} />}
<div className="flex flex-1 overflow-hidden">
{/* 主表单区 */}
<div className="flex-1 p-6 overflow-auto">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
<NewTaskForm
products={products}
selectedProduct={selectedProduct}
onSelectProduct={setSelectedProduct}
onProductCreated={handleProductCreated}
editingProduct={editingProduct}
setEditingProduct={setEditingProduct}
benchmarks={benchmarks}
form={form}
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
submitting={submitting}
error={error}
onSubmitAi={(e) => handleSubmit(e, 'ai')}
onSubmitImport={openImport}
/>
<div className="mt-8"><RecentTasksBar /></div>
</div>
{/* 右侧配置预览面板 */}
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} onEditInline={() => setEditingProduct(true)} />}
</div>
<ImportTextModal
open={importOpen}
submitting={submitting}
onClose={() => setImportOpen(false)}
onConfirm={handleImportConfirm}
/>
</div>
);
}