feat: 新版生成流程 - 确认弹窗 + 任务详情页

- 生成弹窗: 展示所有AI块(prompt/模型/备注),支持多文件上传/删除/重传
- 点击生成 → 创建任务 → 提示前往任务历史查看
- 任务详情页: 显示模板名/状态/AI块/提示词/上传文件/下载DOCX+PDF
- 任务历史: 行可点击进入详情,增加详情按钮
- 后端: /tasks/{id}?detail=true 返回含生成点信息的完整详情
- 编辑器: 移除旧进度条+下载按钮,接入新GenerateModal
This commit is contained in:
zwt13703 2026-07-06 21:11:39 +08:00
parent dd2af6be4a
commit 5f87f695fe
8 changed files with 410 additions and 114 deletions

View File

@ -13,7 +13,7 @@ from app.models.generation_task import GenerationTask
from app.models.generation_point import GenerationPoint
from app.models.template import Template
from app.models.ai_model import AIModel
from app.schemas.generation_task import TaskResponse, GenerateResponse, SingleTestRequest, SingleTestResponse
from app.schemas.generation_task import TaskResponse, GenerateResponse, SingleTestRequest, SingleTestResponse, TaskDetailResponse
from app.services.document_processor import docx_to_pdf_bytes
from app.services.file_storage import get_file_content, save_upload, REF_FILES_DIR
from app.services.ai_adapter import call_ai_model
@ -51,12 +51,41 @@ async def trigger_generation(template_id: str, db: AsyncSession = Depends(get_db
return GenerateResponse(task_id=task.id, status="pending")
@router.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task_status(task_id: str, db: AsyncSession = Depends(get_db)):
@router.get("/tasks/{task_id}")
async def get_task_status(task_id: str, detail: bool = Query(False), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if detail:
tpl_result = await db.execute(select(Template).where(Template.id == task.template_id))
tpl = tpl_result.scalar_one_or_none()
points_result = await db.execute(
select(GenerationPoint).where(GenerationPoint.template_id == task.template_id).order_by(GenerationPoint.order.asc())
)
points = points_result.scalars().all()
return TaskDetailResponse(
id=task.id,
template_id=task.template_id,
template_name=tpl.name if tpl else "",
status=task.status,
result_file_path=task.result_file_path,
error_msg=task.error_msg,
created_at=task.created_at,
finished_at=task.finished_at,
points=[{
"id": str(p.id),
"prompt": p.prompt,
"position": p.position,
"model_id": str(p.model_id) if p.model_id else None,
"ref_file_path": p.ref_file_path,
"selected_text": p.selected_text,
"need_ref_file": p.need_ref_file,
"remark": p.remark,
"order": p.order,
} for p in points],
)
return task

View File

@ -15,6 +15,20 @@ class TaskResponse(BaseModel):
model_config = {"from_attributes": True}
class TaskDetailResponse(BaseModel):
id: uuid.UUID
template_id: uuid.UUID
template_name: str = ""
status: str
result_file_path: str | None = None
error_msg: str | None = None
created_at: datetime
finished_at: datetime | None = None
points: list[dict] = []
model_config = {"from_attributes": True}
class GenerateResponse(BaseModel):
task_id: uuid.UUID
status: str

View File

@ -4,6 +4,7 @@ import TemplateList from "./pages/TemplateList";
import TemplateEditor from "./pages/TemplateEditor";
import ModelList from "./pages/ModelList";
import TaskList from "./pages/TaskList";
import TaskDetail from "./pages/TaskDetail";
import Settings from "./pages/Settings";
function App() {
@ -15,6 +16,7 @@ function App() {
<Route path="/editor/:id" element={<TemplateEditor />} />
<Route path="/models" element={<ModelList />} />
<Route path="/tasks" element={<TaskList />} />
<Route path="/tasks/:id" element={<TaskDetail />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>

View File

@ -0,0 +1,179 @@
import { useState } from "react";
import {
Modal, Button, Space, message, Card, Tag, Upload, List, Popconfirm,
} from "antd";
import {
UploadOutlined, DeleteOutlined, FileTextOutlined,
ThunderboltOutlined, PaperClipOutlined,
} from "@ant-design/icons";
import type { GenerationPoint, AIModel } from "../types";
import * as taskApi from "../api/tasks";
import request from "../api/request";
interface GenerateModalProps {
open: boolean;
onClose: () => void;
templateId: string;
points: GenerationPoint[];
models: AIModel[];
}
interface FileItem {
uid: string;
name: string;
path?: string;
}
export default function GenerateModal({
open, onClose, templateId, points, models,
}: GenerateModalProps) {
const [filesMap, setFilesMap] = useState<Record<string, FileItem[]>>({});
const [generating, setGenerating] = useState(false);
const handleUpload = async (pointId: string, fileList: File[]) => {
if (fileList.length === 0) return;
const formData = new FormData();
fileList.forEach((f) => formData.append("ref_files", f));
await request.post(`/generation-points/${pointId}/upload-ref`, formData);
const newFiles = fileList.map((f) => ({
uid: f.name + Date.now(),
name: f.name,
}));
setFilesMap((prev) => ({
...prev,
[pointId]: [...(prev[pointId] || []), ...newFiles],
}));
message.success(`已上传 ${fileList.length} 个文件`);
};
const handleRemoveFile = (pointId: string, fileName: string) => {
setFilesMap((prev) => ({
...prev,
[pointId]: (prev[pointId] || []).filter((f) => f.name !== fileName),
}));
};
const handleGenerate = async () => {
setGenerating(true);
try {
const { task_id } = await taskApi.triggerGeneration(templateId);
message.success("已创建生成任务,请前往任务历史查看");
onClose();
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
} finally {
setGenerating(false);
}
};
const getModelName = (modelId: string | null) => {
if (!modelId) return "默认模型";
const m = models.find((x) => x.id === modelId);
return m?.name || modelId;
};
const pointsNeedRef = points.filter((p) => p.need_ref_file);
const pointsNoRef = points.filter((p) => !p.need_ref_file);
return (
<Modal
title="确认生成文档"
open={open}
onCancel={onClose}
width={700}
footer={
<Space>
<Button onClick={onClose}></Button>
<Button
type="primary"
danger
icon={<ThunderboltOutlined />}
onClick={handleGenerate}
loading={generating}
>
</Button>
</Space>
}
>
<p style={{ marginBottom: 16, color: "#666" }}>
{points.length} AI
</p>
{pointsNeedRef.length > 0 && (
<div style={{ marginBottom: 12 }}>
<Tag color="orange"> ({pointsNeedRef.length})</Tag>
</div>
)}
{points.map((point, idx) => {
const uploadedFiles = filesMap[point.id] || [];
const modelName = getModelName(point.model_id);
return (
<Card
key={point.id}
size="small"
style={{ marginBottom: 12 }}
title={
<Space size={4}>
<span style={{ fontSize: 12, color: "#999" }}>#{idx + 1}</span>
<span style={{ fontSize: 13 }}>{point.prompt.slice(0, 30)}{point.prompt.length > 30 ? "..." : ""}</span>
</Space>
}
extra={
<Tag color="blue" style={{ fontSize: 11 }}>{modelName}</Tag>
}
>
{point.remark && (
<p style={{ fontSize: 12, color: "#999", marginBottom: 8 }}>
{point.remark}
</p>
)}
{point.need_ref_file && (
<div>
<Upload
multiple
accept=".txt,.docx,.pdf"
showUploadList={false}
beforeUpload={(file) => {
handleUpload(point.id, [file]);
return false;
}}
>
<Button size="small" icon={<UploadOutlined />}>
</Button>
</Upload>
{uploadedFiles.length > 0 && (
<List
size="small"
style={{ marginTop: 8 }}
dataSource={uploadedFiles}
renderItem={(item) => (
<List.Item
extra={
<Popconfirm
title="删除此文件?"
onConfirm={() => handleRemoveFile(point.id, item.name)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<FileTextOutlined style={{ marginRight: 6 }} />
<span style={{ fontSize: 12 }}>{item.name}</span>
</List.Item>
)}
/>
)}
</div>
)}
</Card>
);
})}
</Modal>
);
}

View File

@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Card, Tag, Button, Space, Spin, Descriptions, List, Empty,
} from "antd";
import {
ArrowLeftOutlined, DownloadOutlined, FileTextOutlined,
} from "@ant-design/icons";
import request from "../api/request";
import type { GenerationPoint, AIModel } from "../types";
import * as modelApi from "../api/models";
import * as taskApi from "../api/tasks";
interface TaskDetailData {
id: string;
template_id: string;
template_name: string;
status: string;
result_file_path: string | null;
error_msg: string | null;
created_at: string;
finished_at: string | null;
points: GenerationPoint[];
}
const STATUS_MAP: Record<string, { color: string; label: string }> = {
pending: { color: "default", label: "等待中" },
processing: { color: "processing", label: "生成中" },
done: { color: "success", label: "已完成" },
failed: { color: "error", label: "失败" },
};
export default function TaskDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [task, setTask] = useState<TaskDetailData | null>(null);
const [models, setModels] = useState<AIModel[]>([]);
const [loading, setLoading] = useState(true);
const fetchTask = async () => {
if (!id) return;
setLoading(true);
try {
const { data } = await request.get<TaskDetailData>(`/tasks/${id}`, { params: { detail: true } });
setTask(data);
} catch {
setTask(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
Promise.all([
fetchTask(),
modelApi.listModels({ enabled: true }).then(setModels).catch(() => {}),
]);
}, [id]);
useEffect(() => {
if (!task || task.status === "done" || task.status === "failed") return;
const timer = setInterval(fetchTask, 3000);
return () => clearInterval(timer);
}, [task?.status]);
const getModelName = (modelId: string | null) => {
if (!modelId) return "默认模型";
return models.find((m) => m.id === modelId)?.name || modelId;
};
if (loading) return <Spin style={{ display: "block", marginTop: 100 }} />;
if (!task) return <Empty description="任务不存在" />;
const status = STATUS_MAP[task.status] || { color: "default", label: task.status };
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/tasks")}></Button>
<h3 style={{ margin: 0 }}></h3>
</Space>
{task.status === "done" && (
<Space>
<Button icon={<DownloadOutlined />} onClick={() => window.open(taskApi.getTaskDownloadUrl(task.id))}>
DOCX
</Button>
<Button onClick={() => window.open(taskApi.getTaskDownloadUrl(task.id, "pdf"))}>
PDF
</Button>
</Space>
)}
</div>
<Card style={{ marginBottom: 16 }}>
<Descriptions column={3} size="small">
<Descriptions.Item label="模板名称">{task.template_name}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={status.color}>{status.label}</Tag></Descriptions.Item>
<Descriptions.Item label="创建时间">{new Date(task.created_at).toLocaleString()}</Descriptions.Item>
{task.finished_at && (
<Descriptions.Item label="完成时间">{new Date(task.finished_at).toLocaleString()}</Descriptions.Item>
)}
{task.error_msg && (
<Descriptions.Item label="错误信息" span={3}>
<span style={{ color: "red" }}>{task.error_msg}</span>
</Descriptions.Item>
)}
</Descriptions>
</Card>
<h4 style={{ marginBottom: 12 }}>AI ({task.points.length})</h4>
{task.points.map((point, idx) => {
const modelName = getModelName(point.model_id);
const hasFiles = point.ref_file_path && point.ref_file_path !== "[]";
let fileNames: string[] = [];
if (hasFiles) {
try { fileNames = JSON.parse(point.ref_file_path || "[]").map((f: string) => f.split("/").pop() || f); } catch {}
}
return (
<Card
key={point.id}
size="small"
style={{ marginBottom: 8 }}
title={
<Space size={4}>
<span style={{ fontSize: 12, color: "#999" }}>#{idx + 1}</span>
<span style={{ fontSize: 13 }}>{point.prompt.slice(0, 40)}{point.prompt.length > 40 ? "..." : ""}</span>
</Space>
}
extra={<Tag color="blue">{modelName}</Tag>}
>
{point.remark && <p style={{ fontSize: 12, color: "#999", marginBottom: 8 }}>{point.remark}</p>}
{point.need_ref_file && (
<div>
{hasFiles && fileNames.length > 0 ? (
<List
size="small"
dataSource={fileNames}
renderItem={(name: string) => (
<List.Item><FileTextOutlined style={{ marginRight: 6 }} />{name}</List.Item>
)}
/>
) : (
<span style={{ fontSize: 12, color: "#999" }}></span>
)}
</div>
)}
</Card>
);
})}
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Table, Button, Space, Tag, message, Popconfirm } from "antd";
import { DownloadOutlined, StopOutlined, ReloadOutlined } from "@ant-design/icons";
import { DownloadOutlined, StopOutlined, ReloadOutlined, EyeOutlined } from "@ant-design/icons";
import type { GenerationTask } from "../types";
import * as taskApi from "../api/tasks";
@ -12,6 +13,7 @@ const STATUS_MAP: Record<string, { color: string; label: string }> = {
};
export default function TaskList() {
const navigate = useNavigate();
const [tasks, setTasks] = useState<GenerationTask[]>([]);
const [loading, setLoading] = useState(false);
@ -81,6 +83,13 @@ export default function TaskList() {
width: 200,
render: (_: unknown, record: GenerationTask) => (
<Space>
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/tasks/${record.id}`)}
>
</Button>
{record.status === "done" && (
<>
<Button
@ -124,6 +133,10 @@ export default function TaskList() {
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
onRow={(record) => ({
style: { cursor: "pointer" },
onClick: () => navigate(`/tasks/${record.id}`),
})}
/>
</div>
);

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useRef, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Layout, Button, Modal, Form, Input, Select,
Space, message, Spin, Progress, Popconfirm, Card, Switch,
Space, message, Spin, Popconfirm, Card, Switch,
} from "antd";
import {
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
@ -12,11 +12,11 @@ import {
} from "@ant-design/icons";
import { Editor } from "@tinymce/tinymce-react";
import type { Editor as TinyMCEEditor } from "tinymce";
import type { GenerationPoint, AIModel, GenerationTask } from "../types";
import type { GenerationPoint, AIModel } from "../types";
import * as templateApi from "../api/templates";
import * as pointApi from "../api/generationPoints";
import * as modelApi from "../api/models";
import * as taskApi from "../api/tasks";
import GenerateModal from "../components/GenerateModal";
const { Sider, Content } = Layout;
@ -38,10 +38,8 @@ export default function TemplateEditor() {
const [selBtnPos, setSelBtnPos] = useState({ x: 0, y: 0 });
const [pointForm] = Form.useForm();
const [panelCollapsed, setPanelCollapsed] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [generating, setGenerating] = useState(false);
const [taskStatus, setTaskStatus] = useState<GenerationTask | null>(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
@ -266,94 +264,13 @@ export default function TemplateEditor() {
}
};
const handleGenerate = async () => {
const handleGenerate = () => {
if (!id) return;
if (points.length === 0) {
message.warning("请先添加至少一个生成点");
return;
}
const needsRef = points.filter((p) => p.need_ref_file && !p.ref_file_path);
if (needsRef.length > 0) {
Modal.confirm({
title: "需要上传参考文件",
content: `${needsRef.length} 个生成点需要参考文件但未上传,请先上传后再生成。`,
okText: "去上传",
cancelText: "取消",
onOk: () => {
// 逐个提示上传
uploadRefForPoints(needsRef.map((p) => p.id));
},
});
return;
}
setGenerating(true);
try {
const { task_id } = await taskApi.triggerGeneration(id);
pollingRef.current = setInterval(async () => {
try {
const status = await taskApi.getTaskStatus(task_id);
setTaskStatus(status);
if (status.status === "done" || status.status === "failed") {
if (pollingRef.current) clearInterval(pollingRef.current);
if (status.status === "done") {
message.success("生成完成!");
} else {
message.error(`生成失败: ${status.error_msg}`);
}
setGenerating(false);
}
} catch {
if (pollingRef.current) clearInterval(pollingRef.current);
setGenerating(false);
}
}, 2000);
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
setGenerating(false);
}
};
const uploadRefForPoints = async (pointIds: string[]) => {
for (const pointId of pointIds) {
const point = points.find((p) => p.id === pointId);
if (!point) continue;
await new Promise<void>((resolve) => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.multiple = true;
fileInput.accept = ".txt,.docx,.pdf";
fileInput.onchange = async (e: Event) => {
const files = (e.target as HTMLInputElement).files;
if (!files || files.length === 0) { resolve(); return; }
try {
message.loading({ content: `上传 ${point.prompt.slice(0, 20)}...`, key: "upload" });
const formData = new FormData();
Array.from(files).forEach((f) => formData.append("ref_files", f));
const { default: request } = await import("../api/request");
await request.post(`/generation-points/${pointId}/upload-ref`, formData);
message.success({ content: "上传成功", key: "upload" });
// 刷新列表
const updated = await pointApi.listGenerationPoints(id!);
setPoints(updated);
} catch {
message.error({ content: "上传失败", key: "upload" });
}
resolve();
};
message.info(`请为「${point.prompt.slice(0, 30)}」上传参考文件`);
fileInput.click();
});
}
message.success("所有参考文件上传完成,请再次点击生成文档");
};
const handleDownload = (format: "docx" | "pdf" = "docx") => {
if (taskStatus?.id) {
window.open(taskApi.getTaskDownloadUrl(taskStatus.id, format));
}
setGenerateModalOpen(true);
};
if (loading) {
@ -393,32 +310,11 @@ export default function TemplateEditor() {
danger
icon={<ThunderboltOutlined />}
onClick={handleGenerate}
loading={generating}
>
</Button>
{taskStatus?.status === "done" && (
<>
<Button icon={<DownloadOutlined />} onClick={() => handleDownload("docx")}>
DOCX
</Button>
<Button onClick={() => handleDownload("pdf")}>PDF</Button>
</>
)}
</Space>
</div>
{taskStatus && taskStatus.status !== "done" && taskStatus.status !== "failed" && (
<div style={{ marginBottom: 12 }}>
<Progress
percent={taskStatus.status === "processing" ? 50 : 10}
status="active"
format={() => taskStatus.status === "pending" ? "等待中..." : "生成中..."}
/>
</div>
)}
{taskStatus?.status === "failed" && (
<div style={{ marginBottom: 12, color: "red" }}>: {taskStatus.error_msg}</div>
)}
<Layout style={{ height: "calc(100% - 50px)", background: "#fff" }}>
<Content style={{ position: "relative", overflow: "hidden" }}>
{showSelectionBtn && (
@ -582,6 +478,14 @@ export default function TemplateEditor() {
</Form.Item>
</Form>
</Modal>
<GenerateModal
open={generateModalOpen}
onClose={() => setGenerateModalOpen(false)}
templateId={id || ""}
points={points}
models={models}
/>
</div>
);
}

View File

@ -45,6 +45,7 @@ export interface GenerationPoint {
export interface GenerationTask {
id: string;
template_id: string;
template_name?: string;
status: "pending" | "processing" | "done" | "failed";
result_file_path: string | null;
error_msg: string | null;