完善附件管理与生成任务跟踪
This commit is contained in:
@@ -40,3 +40,8 @@ async def init_db():
|
||||
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning BOOLEAN DEFAULT 0"))
|
||||
else:
|
||||
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning TINYINT(1) DEFAULT 0"))
|
||||
document_columns = await conn.run_sync(
|
||||
lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("documents")]
|
||||
)
|
||||
if "request_payload_json" not in document_columns:
|
||||
await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT"))
|
||||
|
||||
@@ -11,5 +11,6 @@ class Document(Base):
|
||||
status = Column(String(20), default="pending", comment="pending/generating/completed/failed/cancelled")
|
||||
file_path = Column(String(500), default="", comment="生成的文件路径")
|
||||
error = Column(Text, default="", comment="错误信息")
|
||||
request_payload_json = Column(Text, nullable=True, comment="提交任务时的文件与段落配置快照")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
+104
-5
@@ -17,7 +17,7 @@ from models.generation_log import GenerationLog
|
||||
from models.paragraph import Paragraph
|
||||
from models.reference_file import ReferenceFile
|
||||
from models.template import Template
|
||||
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
|
||||
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response
|
||||
from services.ai_service import call_ai, stream_ai_preview
|
||||
from services.file_summary import summarize_minio_files
|
||||
from services.generation_runtime import (
|
||||
@@ -27,12 +27,18 @@ from services.generation_runtime import (
|
||||
run_generation,
|
||||
update_progress,
|
||||
)
|
||||
from services.minio_client import upload_bytes
|
||||
from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_document(document: Document) -> dict:
|
||||
request_payload = {}
|
||||
if document.request_payload_json:
|
||||
try:
|
||||
request_payload = json.loads(document.request_payload_json)
|
||||
except Exception:
|
||||
request_payload = {}
|
||||
return {
|
||||
"id": document.id,
|
||||
"template_id": document.template_id,
|
||||
@@ -42,6 +48,7 @@ def _serialize_document(document: Document) -> dict:
|
||||
"status": document.status,
|
||||
"file_path": document.file_path,
|
||||
"error": document.error,
|
||||
"request_payload": request_payload,
|
||||
"created_at": document.created_at,
|
||||
"updated_at": document.updated_at,
|
||||
}
|
||||
@@ -54,9 +61,25 @@ def _serialize_reference_file(file: ReferenceFile) -> dict:
|
||||
"file_path": file.file_path,
|
||||
"file_size": file.file_size,
|
||||
"content_type": file.content_type,
|
||||
"created_at": file.created_at,
|
||||
"created_at": file.created_at.isoformat() if file.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _build_reference_name_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, str]:
|
||||
if not file_paths:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(ReferenceFile.file_path, ReferenceFile.file_name).where(ReferenceFile.file_path.in_(file_paths))
|
||||
)
|
||||
return {file_path: file_name for file_path, file_name in result.all()}
|
||||
|
||||
|
||||
async def _build_reference_records_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, ReferenceFile]:
|
||||
if not file_paths:
|
||||
return {}
|
||||
result = await db.execute(select(ReferenceFile).where(ReferenceFile.file_path.in_(file_paths)))
|
||||
return {item.file_path: item for item in result.scalars().all()}
|
||||
|
||||
@router.post("/test")
|
||||
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
|
||||
paragraph = await db.get(Paragraph, body.paragraph_id)
|
||||
@@ -71,7 +94,12 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge
|
||||
elif paragraph.model_id:
|
||||
model = await db.get(AiModel, paragraph.model_id)
|
||||
|
||||
file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else []
|
||||
file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or [])
|
||||
file_summaries = (
|
||||
await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping)
|
||||
if body.file_paths
|
||||
else []
|
||||
)
|
||||
if model is None or model.status != "enabled":
|
||||
content = build_mock_content(paragraph)
|
||||
message = "当前未找到可用模型,返回本地模拟生成结果。"
|
||||
@@ -109,7 +137,12 @@ async def generate_test_stream(body: GenerateTestRequest, db: AsyncSession = Dep
|
||||
if not model.supports_streaming:
|
||||
raise HTTPException(status_code=400, detail="当前模型未开启流式传输")
|
||||
|
||||
file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else []
|
||||
file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or [])
|
||||
file_summaries = (
|
||||
await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping)
|
||||
if body.file_paths
|
||||
else []
|
||||
)
|
||||
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
|
||||
|
||||
async def event_stream():
|
||||
@@ -236,6 +269,41 @@ async def list_reference_files(
|
||||
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
|
||||
|
||||
|
||||
@router.get("/reference-files/{file_id}/download")
|
||||
async def download_reference_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
record = await db.get(ReferenceFile, file_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="附件记录不存在")
|
||||
bucket, object_name = split_bucket_path(record.file_path)
|
||||
return Response(data={"url": get_presigned_url(bucket, object_name), "file_name": record.file_name})
|
||||
|
||||
|
||||
@router.put("/reference-files/{file_id}")
|
||||
async def update_reference_file(file_id: int, body: ReferenceFileUpdate, db: AsyncSession = Depends(get_db)):
|
||||
record = await db.get(ReferenceFile, file_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="附件记录不存在")
|
||||
record.file_name = body.file_name.strip()
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return Response(data=_serialize_reference_file(record))
|
||||
|
||||
|
||||
@router.delete("/reference-files/{file_id}")
|
||||
async def delete_reference_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
record = await db.get(ReferenceFile, file_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="附件记录不存在")
|
||||
try:
|
||||
bucket, object_name = split_bucket_path(record.file_path)
|
||||
await asyncio.to_thread(delete_object, bucket, object_name)
|
||||
except Exception:
|
||||
pass
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
return Response(data={"id": file_id})
|
||||
|
||||
|
||||
@router.post("/full")
|
||||
async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)):
|
||||
template = await db.get(Template, body.template_id)
|
||||
@@ -251,6 +319,36 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
|
||||
if not paragraphs:
|
||||
raise HTTPException(status_code=400, detail="模板下暂无可生成段落")
|
||||
|
||||
normalized_file_map: dict[str, list[str]] = {}
|
||||
for key, values in (body.file_map or {}).items():
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
normalized_file_map[str(key)] = [item for item in values if item]
|
||||
all_file_paths = [item for values in normalized_file_map.values() for item in values]
|
||||
file_records = await _build_reference_records_mapping(db, all_file_paths)
|
||||
paragraph_snapshot = []
|
||||
for paragraph in paragraphs:
|
||||
selected_paths = normalized_file_map.get(str(paragraph.id), [])
|
||||
paragraph_snapshot.append(
|
||||
{
|
||||
"paragraph_id": paragraph.id,
|
||||
"title": paragraph.title,
|
||||
"sort_index": paragraph.sort_index,
|
||||
"need_file": bool(paragraph.need_file),
|
||||
"file_note": paragraph.file_note or "",
|
||||
"selected_files": [
|
||||
_serialize_reference_file(file_records[file_path])
|
||||
for file_path in selected_paths
|
||||
if file_path in file_records
|
||||
],
|
||||
}
|
||||
)
|
||||
request_payload = {
|
||||
"template_name": template.name,
|
||||
"file_map": normalized_file_map,
|
||||
"paragraphs": paragraph_snapshot,
|
||||
}
|
||||
|
||||
document = Document(
|
||||
template_id=template.id,
|
||||
name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
@@ -259,6 +357,7 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
|
||||
status="generating",
|
||||
file_path="",
|
||||
error="",
|
||||
request_payload_json=json.dumps(request_payload, ensure_ascii=False),
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
|
||||
@@ -87,7 +87,11 @@ class GenerateTestRequest(BaseModel):
|
||||
|
||||
class GenerateFullRequest(BaseModel):
|
||||
template_id: int
|
||||
file_map: dict[str, str] = {} # paragraph_id -> file_path
|
||||
file_map: dict[str, list[str]] = {} # paragraph_id -> file_paths
|
||||
|
||||
|
||||
class ReferenceFileUpdate(BaseModel):
|
||||
file_name: str = Field(min_length=1, max_length=255)
|
||||
|
||||
class DocumentOut(BaseModel):
|
||||
id: int
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
@@ -38,7 +40,10 @@ def _summarize_csv(content: bytes) -> str:
|
||||
|
||||
def _summarize_excel(content: bytes, suffix: str) -> str:
|
||||
excel_buffer = io.BytesIO(content)
|
||||
sheet_map = pd.read_excel(excel_buffer, sheet_name=None) if suffix == ".xlsx" else pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd")
|
||||
if suffix in {".xlsx", ".xlsm"}:
|
||||
sheet_map = pd.read_excel(excel_buffer, sheet_name=None)
|
||||
else:
|
||||
sheet_map = pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd")
|
||||
parts: list[str] = []
|
||||
for sheet_name, dataframe in list(sheet_map.items())[:5]:
|
||||
preview = dataframe.head(10).fillna("").astype(str)
|
||||
@@ -47,6 +52,21 @@ def _summarize_excel(content: bytes, suffix: str) -> str:
|
||||
return "\n".join(parts)[:5000]
|
||||
|
||||
|
||||
def _summarize_doc(content: bytes) -> str:
|
||||
with tempfile.NamedTemporaryFile(suffix=".doc") as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_file.flush()
|
||||
result = subprocess.run(
|
||||
["textutil", "-convert", "txt", "-stdout", temp_file.name],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="ignore").strip()
|
||||
return f"旧版 Word 文件解析失败:{stderr or 'textutil 无法提取正文'}"
|
||||
return _decode_text(result.stdout)[:4000]
|
||||
|
||||
|
||||
def _summarize_pdf(content: bytes) -> str:
|
||||
if PdfReader is None:
|
||||
return "当前环境未安装 PDF 文本解析依赖,无法提取 PDF 正文。"
|
||||
@@ -68,18 +88,18 @@ def summarize_file_bytes(file_name: str, content: bytes) -> str:
|
||||
if suffix == ".docx":
|
||||
return _summarize_docx(content)
|
||||
if suffix == ".doc":
|
||||
return "当前暂不支持直接解析 .doc 旧版 Word 文件正文,建议先另存为 .docx 后再上传。"
|
||||
return _summarize_doc(content)
|
||||
if suffix == ".pdf":
|
||||
return _summarize_pdf(content)
|
||||
return f"暂不支持解析该文件内容:{file_name}"
|
||||
|
||||
|
||||
def summarize_minio_files(file_paths: list[str]) -> list[dict]:
|
||||
def summarize_minio_files(file_paths: list[str], file_name_mapping: dict[str, str] | None = None) -> list[dict]:
|
||||
summaries: list[dict] = []
|
||||
for file_path in file_paths:
|
||||
bucket, object_name = split_bucket_path(file_path)
|
||||
content = download_object_bytes(bucket, object_name)
|
||||
file_name = Path(object_name).name
|
||||
file_name = (file_name_mapping or {}).get(file_path) or Path(object_name).name
|
||||
summaries.append(
|
||||
{
|
||||
"file_name": file_name,
|
||||
|
||||
@@ -12,6 +12,7 @@ from models.generation_log import GenerationLog
|
||||
from models.paragraph import Paragraph
|
||||
from models.template import Template
|
||||
from services.ai_service import call_ai
|
||||
from services.file_summary import summarize_minio_files
|
||||
|
||||
generation_progress: dict[int, dict] = {}
|
||||
generation_cancel_flags: dict[int, bool] = {}
|
||||
@@ -90,6 +91,13 @@ async def run_generation(document_id: int, template_id: int):
|
||||
)
|
||||
paragraphs = result.scalars().all()
|
||||
total = len(paragraphs)
|
||||
request_payload = {}
|
||||
if document.request_payload_json:
|
||||
try:
|
||||
request_payload = json.loads(document.request_payload_json)
|
||||
except Exception:
|
||||
request_payload = {}
|
||||
file_map = request_payload.get("file_map", {}) if isinstance(request_payload, dict) else {}
|
||||
update_progress(document_id, status="generating", total=total, done=0, percent=0, message="开始生成...")
|
||||
|
||||
done_count = 0
|
||||
@@ -114,11 +122,25 @@ async def run_generation(document_id: int, template_id: int):
|
||||
model = await get_effective_model(paragraph)
|
||||
model_id = model.id if model is not None else paragraph.model_id
|
||||
try:
|
||||
selected_file_paths = file_map.get(str(paragraph.id), [])
|
||||
file_summaries = []
|
||||
if selected_file_paths:
|
||||
file_name_mapping = {}
|
||||
for paragraph_item in request_payload.get("paragraphs", []):
|
||||
for selected_file in paragraph_item.get("selected_files", []):
|
||||
file_path = selected_file.get("file_path")
|
||||
if file_path in selected_file_paths:
|
||||
file_name_mapping[file_path] = selected_file.get("file_name")
|
||||
file_summaries = await asyncio.to_thread(
|
||||
summarize_minio_files,
|
||||
selected_file_paths,
|
||||
file_name_mapping,
|
||||
)
|
||||
if model is None:
|
||||
content = build_mock_content(paragraph)
|
||||
else:
|
||||
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
|
||||
result_data = await call_ai(paragraph, model)
|
||||
result_data = await call_ai(paragraph, model, file_summaries)
|
||||
content = result_data.content
|
||||
status = "success"
|
||||
error_message = ""
|
||||
|
||||
@@ -68,3 +68,7 @@ def upload_bytes(
|
||||
len(content),
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
|
||||
def delete_object(bucket: str, object_name: str):
|
||||
minio_client.remove_object(bucket, object_name)
|
||||
|
||||
@@ -156,3 +156,55 @@
|
||||
3. 优化上传接口错误提示,返回实际不支持的扩展名和当前支持列表。
|
||||
4. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。
|
||||
- **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。
|
||||
|
||||
## 会话 ID: local-20260702173843
|
||||
- [2026-07-02 17:38:43]
|
||||
- **执行原因**: 用户要求先提交当前代码,并继续修复参考文件原名称丢失问题,同时核实 `.doc`、Excel、PDF 的内容提取能力。
|
||||
- **执行过程**:
|
||||
1. 提交当前阶段代码,提交信息为“完善模型测试与参考文件历史能力”,保持工作区清晰。
|
||||
2. 修正参考文件摘要逻辑,优先从 `reference_files` 历史记录中读取原始文件名,不再把 MinIO 中的 UUID 文件名传给 AI。
|
||||
3. 为 `.doc` 文件接入 macOS `textutil` 文本提取能力,补齐旧版 Word 文档正文解析。
|
||||
4. 修正 Excel 提取分支,确保 `.xlsm` 与 `.xlsx` 走正确读取方式;保留现有 PDF 文本提取逻辑。
|
||||
5. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
|
||||
- **执行结果**: 当前参考文件在提交给 AI 时会保留用户原始文件名;`.doc`、`.docx`、Excel、PDF 等常见文件均可进入提取链路,其中 Excel 和 PDF 原本已支持,本轮补强了 `.doc` 与 `.xlsm`。
|
||||
|
||||
## 会话 ID: local-20260702175543
|
||||
- [2026-07-02 17:55:43]
|
||||
- **执行原因**: 用户提出系统缺少单独的附件历史菜单,希望可以直接查看已经上传过的文件。
|
||||
- **执行过程**:
|
||||
1. 新增前端附件历史页面,展示参考文件总数、当前页数量、文件名、对象路径、类型、大小和上传时间。
|
||||
2. 将页面接入现有 `reference-files` 接口,支持按文件名搜索和分页查看。
|
||||
3. 在顶部导航中新增“附件历史”菜单入口,并补充路由配置。
|
||||
4. 执行前端类型检查,确认本轮改动稳定。
|
||||
- **执行结果**: 当前系统已新增独立的“附件历史”菜单,用户可直接查看和检索已上传的参考文件记录。
|
||||
|
||||
## 会话 ID: local-20260702181456
|
||||
- [2026-07-02 18:14:56]
|
||||
- **执行原因**: 用户希望附件历史支持下载、改名、查看与删除,同时要求生成页面支持多文件和文件说明,并让生成任务脱离当前页面、在记录页可持续查看状态与附件映射。
|
||||
- **执行过程**:
|
||||
1. 扩展参考附件接口,新增附件下载、改名、删除能力,并在附件历史页补齐对应操作按钮。
|
||||
2. 为生成任务数据模型新增请求快照字段,在提交任务时持久化保存模板名、段落文件说明以及每个段落选中的附件列表。
|
||||
3. 调整正式生成运行时,让每个段落在后台生成时真正读取该段落绑定的多文件内容,并把原始文件名传给 AI。
|
||||
4. 重写执行生成页,展示每个段落的文件说明,支持多文件上传与移除,并将页面改为“提交任务后立即转到任务详情”模式,不再绑死在提交页。
|
||||
5. 重写任务详情页,支持查看任务状态、实时进度、段落与附件映射、已完成段落内容,以及在运行中取消任务。
|
||||
6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
|
||||
- **执行结果**: 当前附件历史已支持基础管理;生成任务提交后会在后台继续执行,用户可离开提交页并在任务详情中持续查看状态、段落附件映射与生成结果。
|
||||
|
||||
## 会话 ID: local-20260702182202
|
||||
- [2026-07-02 18:22:02]
|
||||
- **执行原因**: 用户反馈 MySQL 启动时报 `TEXT` 列不能设置默认值,需要修复 `request_payload_json` 字段的数据库兼容性。
|
||||
- **执行过程**:
|
||||
1. 调整 `documents.request_payload_json` 的模型定义,取消 `TEXT` 列默认值,改为允许空值并由代码层负责兜底。
|
||||
2. 修正数据库初始化与自动补列逻辑,避免执行 `ALTER TABLE ... TEXT DEFAULT '{}'` 这一类 MySQL 非法 SQL。
|
||||
3. 同步更新 `init.sql` 中 `documents` 表定义,去掉 `request_payload_json` 的默认值。
|
||||
4. 执行后端语法检查,确认修复稳定。
|
||||
- **执行结果**: 当前 `request_payload_json` 字段已兼容 MySQL,应用启动时不会再因为 `TEXT DEFAULT` 语句失败。
|
||||
|
||||
## 会话 ID: local-20260702182417
|
||||
- [2026-07-02 18:24:17]
|
||||
- **执行原因**: 用户反馈提交生成任务时报 `Object of type datetime is not JSON serializable`,需要修复任务快照里的附件时间字段序列化。
|
||||
- **执行过程**:
|
||||
1. 检查生成任务快照构建逻辑,确认报错来源于附件记录中的 `created_at` 为 `datetime` 对象。
|
||||
2. 调整参考附件序列化方法,将 `created_at` 统一转为 ISO 字符串后再写入任务快照 JSON。
|
||||
3. 执行后端语法检查,确认修复稳定。
|
||||
- **执行结果**: 当前生成任务提交时不会再因为附件记录中的 `datetime` 字段导致 JSON 序列化失败。
|
||||
|
||||
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS documents (
|
||||
status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/generating/completed/failed/cancelled',
|
||||
file_path VARCHAR(500) DEFAULT '' COMMENT 'MinIO 对象路径',
|
||||
error TEXT DEFAULT '' COMMENT '错误信息',
|
||||
request_payload_json TEXT COMMENT '提交任务时的文件与段落配置快照',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||
|
||||
+5
-1
@@ -20,6 +20,10 @@
|
||||
<thunderbolt-outlined />
|
||||
执行生成
|
||||
</button>
|
||||
<button :class="['topbar-tab', { active: isActive('/attachments') }]" @click="router.push('/attachments')">
|
||||
<paper-clip-outlined />
|
||||
附件历史
|
||||
</button>
|
||||
<button :class="['topbar-tab', { active: isActive('/history') }]" @click="router.push('/history')">
|
||||
<clock-circle-outlined />
|
||||
生成记录
|
||||
@@ -44,7 +48,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -5,6 +5,9 @@ export const generateApi = {
|
||||
testStream: () => '/api/v1/generate/test-stream',
|
||||
upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||
referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }),
|
||||
updateReferenceFile: (id: number, data: any) => http.put(`/generate/reference-files/${id}`, data),
|
||||
deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`),
|
||||
downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`),
|
||||
full: (data: any) => http.post('/generate/full', data),
|
||||
progress: (id: number) => `/api/v1/generate/progress/${id}`,
|
||||
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
|
||||
|
||||
@@ -6,6 +6,7 @@ const routes = [
|
||||
{ path: '/templates/:id/edit', name: 'TemplateEditor', component: () => import('@/views/TemplateEditor.vue') },
|
||||
{ path: '/models', name: 'ModelManage', component: () => import('@/views/ModelManage.vue') },
|
||||
{ path: '/generate', name: 'GeneratePage', component: () => import('@/views/GeneratePage.vue') },
|
||||
{ path: '/attachments', name: 'AttachmentHistoryPage', component: () => import('@/views/AttachmentHistoryPage.vue') },
|
||||
{ path: '/history', name: 'HistoryPage', component: () => import('@/views/HistoryPage.vue') },
|
||||
{ path: '/preview/:id', name: 'PreviewEdit', component: () => import('@/views/PreviewEdit.vue') },
|
||||
]
|
||||
|
||||
@@ -21,6 +21,18 @@ export interface Document {
|
||||
para_count_done: number; para_count_total: number
|
||||
status: 'pending' | 'generating' | 'completed' | 'failed' | 'cancelled'
|
||||
file_path: string; error: string
|
||||
request_payload?: {
|
||||
template_name?: string
|
||||
file_map?: Record<string, string[]>
|
||||
paragraphs?: Array<{
|
||||
paragraph_id: number
|
||||
title: string
|
||||
sort_index: number
|
||||
need_file: boolean
|
||||
file_note: string
|
||||
selected_files: ReferenceFile[]
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReferenceFile {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="attachment-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">附件历史</div>
|
||||
<div class="page-desc">查看系统内已上传的参考文件,便于后续模板测试时直接复用。</div>
|
||||
</div>
|
||||
<a-button @click="fetchList">刷新列表</a-button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">历史附件总数</div>
|
||||
<div class="stat-value">{{ total }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">当前页文件数</div>
|
||||
<div class="stat-value">{{ files.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<a-input-search
|
||||
v-model:value="keyword"
|
||||
placeholder="按文件名搜索附件"
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-card :bordered="false" class="table-card">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="files"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'file_name'">
|
||||
<div class="file-name">{{ record.file_name }}</div>
|
||||
<div class="file-path">{{ record.file_path }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'file_size'">
|
||||
{{ formatFileSize(record.file_size) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="downloadFile(record)">下载</a-button>
|
||||
<a-button size="small" @click="renameFile(record)">改名</a-button>
|
||||
<a-button size="small" danger @click="removeFile(record)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { generateApi } from '@/api/generate'
|
||||
import type { ReferenceFile } from '@/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const files = ref<ReferenceFile[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const columns = [
|
||||
{ title: '文件名', key: 'file_name', dataIndex: 'file_name' },
|
||||
{ title: '文件类型', key: 'content_type', dataIndex: 'content_type', width: 180 },
|
||||
{ title: '文件大小', key: 'file_size', dataIndex: 'file_size', width: 120 },
|
||||
{ title: '上传时间', key: 'created_at', dataIndex: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 220 },
|
||||
]
|
||||
|
||||
const pagination = ref({
|
||||
current: page.value,
|
||||
pageSize: pageSize.value,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showTotal: (count: number) => `共 ${count} 个附件`,
|
||||
})
|
||||
|
||||
function formatFileSize(fileSize: number) {
|
||||
if (fileSize < 1024) return `${fileSize} B`
|
||||
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
|
||||
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : ''
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await generateApi.referenceFiles({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
keyword: keyword.value.trim(),
|
||||
})
|
||||
files.value = response.data?.items || []
|
||||
total.value = response.data?.total || 0
|
||||
pagination.value = {
|
||||
...pagination.value,
|
||||
current: response.data?.page || page.value,
|
||||
pageSize: response.data?.page_size || pageSize.value,
|
||||
total: response.data?.total || 0,
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '加载附件历史失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(record: ReferenceFile) {
|
||||
try {
|
||||
const response: any = await generateApi.downloadReferenceFile(record.id)
|
||||
window.open(response.data?.url, '_blank')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '下载附件失败')
|
||||
}
|
||||
}
|
||||
|
||||
function renameFile(record: ReferenceFile) {
|
||||
const nextName = window.prompt('请输入新的附件名称', record.file_name)
|
||||
if (nextName === null) return
|
||||
if (!nextName.trim()) {
|
||||
message.warning('文件名不能为空')
|
||||
return
|
||||
}
|
||||
generateApi.updateReferenceFile(record.id, { file_name: nextName.trim() }).then(async () => {
|
||||
message.success('附件名称已更新')
|
||||
await fetchList()
|
||||
}).catch((error: any) => {
|
||||
message.error(error.message || '附件改名失败')
|
||||
})
|
||||
}
|
||||
|
||||
function removeFile(record: ReferenceFile) {
|
||||
Modal.confirm({
|
||||
title: '删除附件',
|
||||
content: `确认删除附件“${record.file_name}”吗?`,
|
||||
okText: '删除',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await generateApi.deleteReferenceFile(record.id)
|
||||
message.success('附件已删除')
|
||||
await fetchList()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleTableChange(nextPagination: any) {
|
||||
page.value = nextPagination.current || 1
|
||||
pageSize.value = nextPagination.pageSize || 10
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attachment-page {
|
||||
padding: 24px;
|
||||
background: #f5f6f8;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px 20px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f5f7fb 100%);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -39,15 +39,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="generating" class="status-block">
|
||||
<div class="status-block">
|
||||
<div class="gen-progress">
|
||||
<div class="spinner" />
|
||||
<div class="gen-text">{{ progressText }}</div>
|
||||
</div>
|
||||
<div class="progress-card">
|
||||
<div class="progress-title">生成进度</div>
|
||||
<a-progress :percent="progress" />
|
||||
<a-button danger block @click="cancelGen">取消生成</a-button>
|
||||
<div class="gen-text">
|
||||
这里仅负责提交生成任务。任务创建后会在后台继续执行,你可以离开当前页面,稍后在“生成记录”或任务详情中查看进度。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,14 +57,22 @@
|
||||
<div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]">
|
||||
<div class="para-info">
|
||||
<span class="idx">{{ paragraph.sort_index }}</span>
|
||||
<span>{{ paragraph.title }}</span>
|
||||
<div class="para-main">
|
||||
<span>{{ paragraph.title }}</span>
|
||||
<span v-if="paragraph.need_file && paragraph.file_note" class="file-note">{{ paragraph.file_note }}</span>
|
||||
</div>
|
||||
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
|
||||
</div>
|
||||
<div v-if="paragraph.need_file" class="file-info">
|
||||
<a-upload :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
|
||||
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id] ? '已上传' : '上传文件' }}</a-button>
|
||||
<a-upload :multiple="true" :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
|
||||
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }}</a-button>
|
||||
</a-upload>
|
||||
<span v-if="uploadedFiles[paragraph.id]" class="uploaded-name">{{ uploadedFiles[paragraph.id] }}</span>
|
||||
<div v-if="uploadedFiles[paragraph.id]?.length" class="uploaded-list">
|
||||
<span v-for="item in uploadedFiles[paragraph.id]" :key="item.file_path" class="uploaded-name">
|
||||
{{ item.file_name }}
|
||||
<button class="remove-file-btn" @click="removeUploadedFile(paragraph.id, item.file_path)">x</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="no-file-tag">无需上传</span>
|
||||
</div>
|
||||
@@ -85,7 +89,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useTemplateStore } from '@/stores/template'
|
||||
@@ -100,19 +104,15 @@ const docStore = useDocumentStore()
|
||||
const templates = ref<any[]>([])
|
||||
const paragraphs = ref<any[]>([])
|
||||
const selectedTplId = ref<number | undefined>(undefined)
|
||||
const uploadedFiles = ref<Record<number, string>>({})
|
||||
const uploadedFilePaths = ref<Record<number, string>>({})
|
||||
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
|
||||
const uploadedFilePaths = ref<Record<number, string[]>>({})
|
||||
const uploadingMap = ref<Record<number, boolean>>({})
|
||||
const generating = ref(false)
|
||||
const progress = ref(0)
|
||||
const progressText = ref('')
|
||||
const tplInfo = ref<any>({})
|
||||
const currentDocumentId = ref<number | null>(null)
|
||||
let progressSource: EventSource | null = null
|
||||
|
||||
const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length)
|
||||
const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length)
|
||||
const fileCount = computed(() => Object.keys(uploadedFiles.value).length)
|
||||
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
|
||||
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
|
||||
|
||||
async function refreshTemplates() {
|
||||
@@ -120,37 +120,6 @@ async function refreshTemplates() {
|
||||
templates.value = tplStore.templates as any
|
||||
}
|
||||
|
||||
function closeProgressSource() {
|
||||
if (progressSource) {
|
||||
progressSource.close()
|
||||
progressSource = null
|
||||
}
|
||||
}
|
||||
|
||||
function bindProgress(documentId: number) {
|
||||
closeProgressSource()
|
||||
progressSource = new EventSource(generateApi.progress(documentId))
|
||||
progressSource.addEventListener('progress', (event: MessageEvent) => {
|
||||
const payload = JSON.parse(event.data)
|
||||
progress.value = payload.percent || 0
|
||||
progressText.value = payload.message || '正在生成...'
|
||||
|
||||
if (payload.status === 'completed' || payload.status === 'failed') {
|
||||
generating.value = false
|
||||
closeProgressSource()
|
||||
message.success(payload.status === 'completed' ? '生成完成' : '生成结束,部分段落已回退为模拟结果')
|
||||
router.push(`/preview/${documentId}`)
|
||||
} else if (payload.status === 'cancelled') {
|
||||
generating.value = false
|
||||
closeProgressSource()
|
||||
message.info('已取消生成')
|
||||
}
|
||||
})
|
||||
progressSource.onerror = () => {
|
||||
closeProgressSource()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshTemplates()
|
||||
const queryId = Number(route.query.templateId)
|
||||
@@ -160,10 +129,6 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeProgressSource()
|
||||
})
|
||||
|
||||
async function onTplChange(id: number) {
|
||||
const template = await tplStore.fetchOne(id)
|
||||
paragraphs.value = tplStore.paragraphs as any
|
||||
@@ -181,8 +146,9 @@ async function handleFileUpload(paragraphId: number, file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res: any = await generateApi.upload(fd)
|
||||
uploadedFiles.value[paragraphId] = res.data.file_name
|
||||
uploadedFilePaths.value[paragraphId] = res.data.file_path
|
||||
const nextFile = { file_name: res.data.file_name, file_path: res.data.file_path }
|
||||
uploadedFiles.value[paragraphId] = [...(uploadedFiles.value[paragraphId] || []), nextFile]
|
||||
uploadedFilePaths.value[paragraphId] = [...(uploadedFilePaths.value[paragraphId] || []), res.data.file_path]
|
||||
message.success('文件上传成功')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '文件上传失败')
|
||||
@@ -192,6 +158,11 @@ async function handleFileUpload(paragraphId: number, file: File) {
|
||||
return false
|
||||
}
|
||||
|
||||
function removeUploadedFile(paragraphId: number, filePath: string) {
|
||||
uploadedFiles.value[paragraphId] = (uploadedFiles.value[paragraphId] || []).filter((item) => item.file_path !== filePath)
|
||||
uploadedFilePaths.value[paragraphId] = (uploadedFilePaths.value[paragraphId] || []).filter((item) => item !== filePath)
|
||||
}
|
||||
|
||||
async function startGen() {
|
||||
if (!selectedTplId.value) {
|
||||
message.warning('请先选择模板')
|
||||
@@ -204,24 +175,18 @@ async function startGen() {
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
progress.value = 0
|
||||
progressText.value = '任务创建中...'
|
||||
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value]))
|
||||
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value || []]))
|
||||
|
||||
try {
|
||||
const document: any = await docStore.generateFull({ template_id: selectedTplId.value, file_map: fileMap })
|
||||
currentDocumentId.value = document.id
|
||||
bindProgress(document.id)
|
||||
message.success('生成任务已提交,已转到任务详情页继续查看进度')
|
||||
router.push(`/preview/${document.id}`)
|
||||
} catch (error: any) {
|
||||
generating.value = false
|
||||
message.error(error.message || '生成失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelGen() {
|
||||
if (!currentDocumentId.value) return
|
||||
await docStore.cancel(currentDocumentId.value)
|
||||
progressText.value = '正在取消...'
|
||||
generating.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -400,7 +365,32 @@ async function cancelGen() {
|
||||
|
||||
.para-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.para-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-note {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.uploaded-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -419,7 +409,22 @@ async function cancelGen() {
|
||||
|
||||
.uploaded-name {
|
||||
color: #1a8c4a;
|
||||
margin-left: 8px;
|
||||
border: 1px solid #cdebd8;
|
||||
background: #eefbf2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.remove-file-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.no-file-tag {
|
||||
|
||||
+302
-121
@@ -1,70 +1,78 @@
|
||||
<template>
|
||||
<div style="padding:24px">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<span style="display:flex;align-items:center;gap:8px">
|
||||
<file-text-outlined />
|
||||
文档预览
|
||||
</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button-group>
|
||||
<a-button @click="undo"><undo-outlined />撤销</a-button>
|
||||
<a-button @click="redo"><redo-outlined />重做</a-button>
|
||||
</a-button-group>
|
||||
<a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button>
|
||||
<a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button>
|
||||
</template>
|
||||
<div style="display:flex;gap:16px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap">
|
||||
<a-button size="small"><b>B</b></a-button>
|
||||
<a-button size="small"><i>I</i></a-button>
|
||||
<a-button size="small"><u>U</u></a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button size="small"><font-size-outlined /></a-button>
|
||||
<a-button size="small"><ordered-list-outlined /></a-button>
|
||||
<a-button size="small"><table-outlined /></a-button>
|
||||
</div>
|
||||
<div
|
||||
ref="editorRef"
|
||||
contenteditable="true"
|
||||
style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8;background:#fff"
|
||||
v-html="editorHtml"
|
||||
@input="onEdit"
|
||||
/>
|
||||
</div>
|
||||
<div style="width:220px;flex-shrink:0">
|
||||
<a-card title="文档结构" size="small">
|
||||
<div
|
||||
v-for="s in structure"
|
||||
:key="s.id"
|
||||
class="struct-item"
|
||||
@click="scrollTo(s.anchor)"
|
||||
>
|
||||
<file-text-outlined />
|
||||
{{ s.title }}
|
||||
<div class="detail-page">
|
||||
<div class="detail-head">
|
||||
<div>
|
||||
<div class="detail-title">任务详情</div>
|
||||
<div class="detail-desc">查看当前生成状态、已选附件,以及已完成段落的输出结果。</div>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<a-button v-if="isRunning" danger @click="cancelTask">取消任务</a-button>
|
||||
<a-button :disabled="!isCompleted" @click="exportDocx">导出 Word</a-button>
|
||||
<a-button :disabled="!isCompleted" @click="exportPdf">导出 PDF</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">任务状态</div>
|
||||
<div class="stat-value">{{ statusText(documentInfo.status) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">完成进度</div>
|
||||
<div class="stat-value">{{ documentInfo.para_count_done || 0 }}/{{ documentInfo.para_count_total || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">关联模板</div>
|
||||
<div class="stat-value">{{ documentInfo.request_payload?.template_name || documentInfo.name || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card class="status-card">
|
||||
<a-badge :status="statusBadge(documentInfo.status)" :text="statusText(documentInfo.status)" />
|
||||
<a-progress style="margin-top: 14px" :percent="progressPercent" />
|
||||
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
|
||||
</a-card>
|
||||
|
||||
<a-card class="mapping-card" title="段落与附件映射">
|
||||
<div v-if="paragraphMappings.length" class="mapping-list">
|
||||
<div v-for="item in paragraphMappings" :key="item.paragraph_id" class="mapping-item">
|
||||
<div class="mapping-top">
|
||||
<span class="mapping-index">{{ item.sort_index }}</span>
|
||||
<div class="mapping-main">
|
||||
<div class="mapping-title">{{ item.title }}</div>
|
||||
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
<div v-if="item.selected_files?.length" class="mapping-files">
|
||||
<span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
|
||||
</div>
|
||||
<div v-else class="mapping-empty">未选择附件</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="当前任务未记录附件映射" />
|
||||
</a-card>
|
||||
|
||||
<a-card class="preview-card" title="生成结果预览">
|
||||
<div v-if="logs.length" class="preview-wrap">
|
||||
<section v-for="item in logs" :key="item.paragraph_id" :id="`section-${item.paragraph_id}`" class="preview-section">
|
||||
<div class="preview-section-head">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" />
|
||||
</div>
|
||||
<div class="preview-block" v-html="renderBlocks(item.content?.content || [])" />
|
||||
</section>
|
||||
</div>
|
||||
<a-empty v-else :description="isRunning ? '任务进行中,已完成段落会逐步出现在这里' : '暂无生成内容'" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { generateApi } from '@/api/generate'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
FileTextOutlined,
|
||||
UndoOutlined,
|
||||
RedoOutlined,
|
||||
FontSizeOutlined,
|
||||
OrderedListOutlined,
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useDocumentStore } from '@/stores/document'
|
||||
import { generateApi } from '@/api/generate'
|
||||
|
||||
interface ContentBlock {
|
||||
type: string
|
||||
@@ -78,23 +86,40 @@ interface LogItem {
|
||||
paragraph_id: number
|
||||
title: string
|
||||
sort_index: number
|
||||
content: {
|
||||
content: ContentBlock[]
|
||||
}
|
||||
status: string
|
||||
content: { content: ContentBlock[] }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const editorRef = ref<HTMLElement | null>(null)
|
||||
const docStore = useDocumentStore()
|
||||
const documentInfo = ref<any>({})
|
||||
const logs = ref<LogItem[]>([])
|
||||
const editorHtml = ref('<p>加载中...</p>')
|
||||
const progressPercent = ref(0)
|
||||
const progressMessage = ref('')
|
||||
let progressSource: EventSource | null = null
|
||||
|
||||
const structure = computed(() =>
|
||||
logs.value.map((item) => ({
|
||||
id: item.paragraph_id,
|
||||
title: item.title || `段落 ${item.sort_index}`,
|
||||
anchor: `section-${item.paragraph_id}`,
|
||||
})),
|
||||
)
|
||||
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
|
||||
const isCompleted = computed(() => documentInfo.value.status === 'completed')
|
||||
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
|
||||
|
||||
function statusText(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
generating: '生成中',
|
||||
pending: '等待中',
|
||||
success: '成功',
|
||||
}
|
||||
return map[status] || status || '未知'
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
if (status === 'completed' || status === 'success') return 'success'
|
||||
if (status === 'failed') return 'error'
|
||||
if (status === 'cancelled') return 'warning'
|
||||
return 'processing'
|
||||
}
|
||||
|
||||
function renderTable(block: ContentBlock) {
|
||||
const headers = block.headers || []
|
||||
@@ -102,19 +127,15 @@ function renderTable(block: ContentBlock) {
|
||||
const thead = headers.length
|
||||
? `<thead><tr>${headers.map((header) => `<th>${header}</th>`).join('')}</tr></thead>`
|
||||
: ''
|
||||
const tbody = `<tbody>${rows
|
||||
.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`)
|
||||
.join('')}</tbody>`
|
||||
return `<table border="1" style="width:100%;border-collapse:collapse;margin:12px 0">${thead}${tbody}</table>`
|
||||
const tbody = `<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody>`
|
||||
return `<table class="result-table">${thead}${tbody}</table>`
|
||||
}
|
||||
|
||||
function renderBlocks(blocks: ContentBlock[]) {
|
||||
return blocks
|
||||
.map((block) => {
|
||||
if (block.type === 'table') {
|
||||
return renderTable(block)
|
||||
}
|
||||
return `<p style="margin:10px 0">${block.text || ''}</p>`
|
||||
if (block.type === 'table') return renderTable(block)
|
||||
return `<p class="result-text">${block.text || ''}</p>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
@@ -122,75 +143,235 @@ function renderBlocks(blocks: ContentBlock[]) {
|
||||
async function loadDocument() {
|
||||
const id = Number(route.params.id)
|
||||
const response: any = await generateApi.getDocument(id)
|
||||
documentInfo.value = response.data || {}
|
||||
logs.value = response.data?.logs || []
|
||||
if (!logs.value.length) {
|
||||
editorHtml.value = '<p>暂无生成内容。</p>'
|
||||
return
|
||||
}
|
||||
|
||||
editorHtml.value = logs.value
|
||||
.map(
|
||||
(item) => `
|
||||
<section id="section-${item.paragraph_id}" style="margin-bottom:24px">
|
||||
<h3 style="margin-bottom:12px">${item.title}</h3>
|
||||
<div style="background:#f8faff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px">
|
||||
${renderBlocks(item.content?.content || [])}
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function scrollTo(anchor: string) {
|
||||
const element = document.getElementById(anchor)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
function bindProgress() {
|
||||
const id = Number(route.params.id)
|
||||
if (progressSource) progressSource.close()
|
||||
progressSource = new EventSource(generateApi.progress(id))
|
||||
progressSource.addEventListener('progress', async (event: MessageEvent) => {
|
||||
const payload = JSON.parse(event.data)
|
||||
progressPercent.value = payload.percent || 0
|
||||
progressMessage.value = payload.message || ''
|
||||
await loadDocument()
|
||||
if (['completed', 'failed', 'cancelled'].includes(payload.status)) {
|
||||
progressSource?.close()
|
||||
progressSource = null
|
||||
}
|
||||
})
|
||||
progressSource.onerror = () => {
|
||||
progressSource?.close()
|
||||
progressSource = null
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit() {}
|
||||
|
||||
function undo() {
|
||||
document.execCommand('undo')
|
||||
}
|
||||
|
||||
function redo() {
|
||||
document.execCommand('redo')
|
||||
async function cancelTask() {
|
||||
const id = Number(route.params.id)
|
||||
await docStore.cancel(id)
|
||||
message.info('已发起取消请求')
|
||||
}
|
||||
|
||||
function exportDocx() {
|
||||
const id = Number(route.params.id)
|
||||
window.open(generateApi.exportDocx(id))
|
||||
window.open(generateApi.exportDocx(Number(route.params.id)))
|
||||
}
|
||||
|
||||
function exportPdf() {
|
||||
const id = Number(route.params.id)
|
||||
window.open(generateApi.exportPdf(id))
|
||||
window.open(generateApi.exportPdf(Number(route.params.id)))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadDocument()
|
||||
progressPercent.value = Math.floor(((documentInfo.value.para_count_done || 0) / Math.max(documentInfo.value.para_count_total || 1, 1)) * 100)
|
||||
if (isRunning.value) bindProgress()
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '加载文档失败')
|
||||
editorHtml.value = '<p>文档加载失败。</p>'
|
||||
message.error(error.message || '加载任务详情失败')
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
progressSource?.close()
|
||||
progressSource = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.struct-item {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
.detail-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.struct-item:hover {
|
||||
background: #f5f5f5;
|
||||
.detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7f8fc 100%);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.mapping-card,
|
||||
.preview-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mapping-item {
|
||||
padding: 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.mapping-top {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mapping-index {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mapping-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mapping-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mapping-note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mapping-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mapping-file {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eefbf2;
|
||||
border: 1px solid #cdebd8;
|
||||
color: #1a8c4a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mapping-empty {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.preview-section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-section-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.result-text) {
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
:deep(.result-table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
:deep(.result-table th),
|
||||
:deep(.result-table td) {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user