diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 03a1dfa..908f035 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -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 diff --git a/backend/app/schemas/generation_task.py b/backend/app/schemas/generation_task.py index 7047358..042fa80 100644 --- a/backend/app/schemas/generation_task.py +++ b/backend/app/schemas/generation_task.py @@ -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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 9a776f9..a87ecf8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/web/src/components/GenerateModal.tsx b/web/src/components/GenerateModal.tsx new file mode 100644 index 0000000..edb4b3b --- /dev/null +++ b/web/src/components/GenerateModal.tsx @@ -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>({}); + 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 ( + + + + + } + > +

+ 共 {points.length} 个 AI 生成点,请确认提示词并上传参考文件后点击生成。 +

+ + {pointsNeedRef.length > 0 && ( +
+ 需要上传文件 ({pointsNeedRef.length}) +
+ )} + + {points.map((point, idx) => { + const uploadedFiles = filesMap[point.id] || []; + const modelName = getModelName(point.model_id); + + return ( + + #{idx + 1} + {point.prompt.slice(0, 30)}{point.prompt.length > 30 ? "..." : ""} + + } + extra={ + {modelName} + } + > + {point.remark && ( +

+ 备注:{point.remark} +

+ )} + + {point.need_ref_file && ( +
+ { + handleUpload(point.id, [file]); + return false; + }} + > + + + + {uploadedFiles.length > 0 && ( + ( + handleRemoveFile(point.id, item.name)} + > +
+ )} +
+ ); + })} +
+ ); +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx new file mode 100644 index 0000000..7ea8303 --- /dev/null +++ b/web/src/pages/TaskDetail.tsx @@ -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 = { + 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(null); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchTask = async () => { + if (!id) return; + setLoading(true); + try { + const { data } = await request.get(`/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 ; + if (!task) return ; + + const status = STATUS_MAP[task.status] || { color: "default", label: task.status }; + + return ( +
+
+ + +

任务详情

+
+ {task.status === "done" && ( + + + + + )} +
+ + + + {task.template_name} + {status.label} + {new Date(task.created_at).toLocaleString()} + {task.finished_at && ( + {new Date(task.finished_at).toLocaleString()} + )} + {task.error_msg && ( + + {task.error_msg} + + )} + + + +

AI 生成点 ({task.points.length})

+ {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 ( + + #{idx + 1} + {point.prompt.slice(0, 40)}{point.prompt.length > 40 ? "..." : ""} + + } + extra={{modelName}} + > + {point.remark &&

备注:{point.remark}

} + {point.need_ref_file && ( +
+ {hasFiles && fileNames.length > 0 ? ( + ( + {name} + )} + /> + ) : ( + 未上传参考文件 + )} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/web/src/pages/TaskList.tsx b/web/src/pages/TaskList.tsx index 0086c3f..b3e13f4 100644 --- a/web/src/pages/TaskList.tsx +++ b/web/src/pages/TaskList.tsx @@ -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 = { }; export default function TaskList() { + const navigate = useNavigate(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(false); @@ -81,6 +83,13 @@ export default function TaskList() { width: 200, render: (_: unknown, record: GenerationTask) => ( + {record.status === "done" && ( <> - {taskStatus?.status === "done" && ( - <> - - - - )} - {taskStatus && taskStatus.status !== "done" && taskStatus.status !== "failed" && ( -
- taskStatus.status === "pending" ? "等待中..." : "生成中..."} - /> -
- )} - {taskStatus?.status === "failed" && ( -
失败: {taskStatus.error_msg}
- )} {showSelectionBtn && ( @@ -582,6 +478,14 @@ export default function TemplateEditor() { + + setGenerateModalOpen(false)} + templateId={id || ""} + points={points} + models={models} + /> ); } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index d6233db..20b88b4 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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;