feat: 新版生成流程 - 确认弹窗 + 任务详情页
- 生成弹窗: 展示所有AI块(prompt/模型/备注),支持多文件上传/删除/重传
- 点击生成 → 创建任务 → 提示前往任务历史查看
- 任务详情页: 显示模板名/状态/AI块/提示词/上传文件/下载DOCX+PDF
- 任务历史: 行可点击进入详情,增加详情按钮
- 后端: /tasks/{id}?detail=true 返回含生成点信息的完整详情
- 编辑器: 移除旧进度条+下载按钮,接入新GenerateModal
This commit is contained in:
parent
dd2af6be4a
commit
5f87f695fe
|
|
@ -13,7 +13,7 @@ from app.models.generation_task import GenerationTask
|
||||||
from app.models.generation_point import GenerationPoint
|
from app.models.generation_point import GenerationPoint
|
||||||
from app.models.template import Template
|
from app.models.template import Template
|
||||||
from app.models.ai_model import AIModel
|
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.document_processor import docx_to_pdf_bytes
|
||||||
from app.services.file_storage import get_file_content, save_upload, REF_FILES_DIR
|
from app.services.file_storage import get_file_content, save_upload, REF_FILES_DIR
|
||||||
from app.services.ai_adapter import call_ai_model
|
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")
|
return GenerateResponse(task_id=task.id, status="pending")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}", response_model=TaskResponse)
|
@router.get("/tasks/{task_id}")
|
||||||
async def get_task_status(task_id: str, db: AsyncSession = Depends(get_db)):
|
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))
|
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
|
||||||
task = result.scalar_one_or_none()
|
task = result.scalar_one_or_none()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
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
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,20 @@ class TaskResponse(BaseModel):
|
||||||
model_config = {"from_attributes": True}
|
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):
|
class GenerateResponse(BaseModel):
|
||||||
task_id: uuid.UUID
|
task_id: uuid.UUID
|
||||||
status: str
|
status: str
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import TemplateList from "./pages/TemplateList";
|
||||||
import TemplateEditor from "./pages/TemplateEditor";
|
import TemplateEditor from "./pages/TemplateEditor";
|
||||||
import ModelList from "./pages/ModelList";
|
import ModelList from "./pages/ModelList";
|
||||||
import TaskList from "./pages/TaskList";
|
import TaskList from "./pages/TaskList";
|
||||||
|
import TaskDetail from "./pages/TaskDetail";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -15,6 +16,7 @@ function App() {
|
||||||
<Route path="/editor/:id" element={<TemplateEditor />} />
|
<Route path="/editor/:id" element={<TemplateEditor />} />
|
||||||
<Route path="/models" element={<ModelList />} />
|
<Route path="/models" element={<ModelList />} />
|
||||||
<Route path="/tasks" element={<TaskList />} />
|
<Route path="/tasks" element={<TaskList />} />
|
||||||
|
<Route path="/tasks/:id" element={<TaskDetail />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Table, Button, Space, Tag, message, Popconfirm } from "antd";
|
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 type { GenerationTask } from "../types";
|
||||||
import * as taskApi from "../api/tasks";
|
import * as taskApi from "../api/tasks";
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ const STATUS_MAP: Record<string, { color: string; label: string }> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TaskList() {
|
export default function TaskList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [tasks, setTasks] = useState<GenerationTask[]>([]);
|
const [tasks, setTasks] = useState<GenerationTask[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -81,6 +83,13 @@ export default function TaskList() {
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_: unknown, record: GenerationTask) => (
|
render: (_: unknown, record: GenerationTask) => (
|
||||||
<Space>
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => navigate(`/tasks/${record.id}`)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
{record.status === "done" && (
|
{record.status === "done" && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -124,6 +133,10 @@ export default function TaskList() {
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{ pageSize: 20 }}
|
||||||
|
onRow={(record) => ({
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
onClick: () => navigate(`/tasks/${record.id}`),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Layout, Button, Modal, Form, Input, Select,
|
Layout, Button, Modal, Form, Input, Select,
|
||||||
Space, message, Spin, Progress, Popconfirm, Card, Switch,
|
Space, message, Spin, Popconfirm, Card, Switch,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
||||||
|
|
@ -12,11 +12,11 @@ import {
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { Editor } from "@tinymce/tinymce-react";
|
import { Editor } from "@tinymce/tinymce-react";
|
||||||
import type { Editor as TinyMCEEditor } from "tinymce";
|
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 templateApi from "../api/templates";
|
||||||
import * as pointApi from "../api/generationPoints";
|
import * as pointApi from "../api/generationPoints";
|
||||||
import * as modelApi from "../api/models";
|
import * as modelApi from "../api/models";
|
||||||
import * as taskApi from "../api/tasks";
|
import GenerateModal from "../components/GenerateModal";
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
|
|
@ -38,10 +38,8 @@ export default function TemplateEditor() {
|
||||||
const [selBtnPos, setSelBtnPos] = useState({ x: 0, y: 0 });
|
const [selBtnPos, setSelBtnPos] = useState({ x: 0, y: 0 });
|
||||||
const [pointForm] = Form.useForm();
|
const [pointForm] = Form.useForm();
|
||||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
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 dragItem = useRef<number | null>(null);
|
||||||
const dragOverItem = 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 (!id) return;
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
message.warning("请先添加至少一个生成点");
|
message.warning("请先添加至少一个生成点");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setGenerateModalOpen(true);
|
||||||
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));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -393,32 +310,11 @@ export default function TemplateEditor() {
|
||||||
danger
|
danger
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
loading={generating}
|
|
||||||
>
|
>
|
||||||
生成文档
|
生成文档
|
||||||
</Button>
|
</Button>
|
||||||
{taskStatus?.status === "done" && (
|
|
||||||
<>
|
|
||||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownload("docx")}>
|
|
||||||
DOCX
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => handleDownload("pdf")}>PDF</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</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" }}>
|
<Layout style={{ height: "calc(100% - 50px)", background: "#fff" }}>
|
||||||
<Content style={{ position: "relative", overflow: "hidden" }}>
|
<Content style={{ position: "relative", overflow: "hidden" }}>
|
||||||
{showSelectionBtn && (
|
{showSelectionBtn && (
|
||||||
|
|
@ -582,6 +478,14 @@ export default function TemplateEditor() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<GenerateModal
|
||||||
|
open={generateModalOpen}
|
||||||
|
onClose={() => setGenerateModalOpen(false)}
|
||||||
|
templateId={id || ""}
|
||||||
|
points={points}
|
||||||
|
models={models}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export interface GenerationPoint {
|
||||||
export interface GenerationTask {
|
export interface GenerationTask {
|
||||||
id: string;
|
id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
|
template_name?: string;
|
||||||
status: "pending" | "processing" | "done" | "failed";
|
status: "pending" | "processing" | "done" | "failed";
|
||||||
result_file_path: string | null;
|
result_file_path: string | null;
|
||||||
error_msg: string | null;
|
error_msg: string | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue