Compare commits

..

5 Commits

Author SHA1 Message Date
zwt13703 17e02fd799 docs: task log 2026-07-05 23:36:23 +08:00
zwt13703 c84ec6aa71 模板在线编辑与导出链路重构 2026-07-05 23:35:00 +08:00
zwt13703 1369d87afb 支持段落删除与移动排序,修复导出残留与外键约束
- 前端:段落列表/配置区/手动编辑区新增上移、下移、删除按钮,hover 显示
- 前端:移动和删除操作后自动保存到后端,修正 canDeleteBlock 判定逻辑
- 后端:保存段落时级联删除关联的 generation_logs 再删段落
- 后端:导出时清理未被引用的标题段落及其内容,避免已删段落残留在 Word 中
- 后端:删除模板时级联清理 generation_logs/documents/paragraphs
2026-07-03 17:13:02 +08:00
zwt13703 a796301ae8 完善模板编辑与模型管理体验 2026-07-03 11:14:24 +08:00
zwt13703 52eb058070 调整任务详情页左右预览布局 2026-07-03 09:28:39 +08:00
25 changed files with 2789 additions and 364 deletions
+12
View File
@@ -22,6 +22,7 @@ async def get_db():
async def init_db(): async def init_db():
from models.template import Template from models.template import Template
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.template_block import TemplateBlock
from models.ai_model import AiModel from models.ai_model import AiModel
from models.document import Document from models.document import Document
from models.generation_log import GenerationLog from models.generation_log import GenerationLog
@@ -45,3 +46,14 @@ async def init_db():
) )
if "request_payload_json" not in document_columns: if "request_payload_json" not in document_columns:
await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT")) await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT"))
paragraph_columns = await conn.run_sync(
lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("paragraphs")]
)
if "anchor_title" not in paragraph_columns:
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN anchor_title VARCHAR(500) DEFAULT ''"))
await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL"))
if "write_mode" not in paragraph_columns:
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'"))
block_tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
if "template_blocks" not in block_tables:
await conn.run_sync(lambda sync_conn: TemplateBlock.__table__.create(sync_conn))
+1
View File
@@ -1,5 +1,6 @@
from models.template import Template from models.template import Template
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.template_block import TemplateBlock
from models.ai_model import AiModel from models.ai_model import AiModel
from models.document import Document from models.document import Document
from models.generation_log import GenerationLog from models.generation_log import GenerationLog
+3 -1
View File
@@ -6,12 +6,14 @@ class Paragraph(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False) template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
sort_index = Column(Integer, default=0, comment="排序") sort_index = Column(Integer, default=0, comment="排序")
anchor_title = Column(String(500), default="", comment="原始标题锚点")
title = Column(String(500), default="", comment="段落标题") title = Column(String(500), default="", comment="段落标题")
content = Column(Text, default="", comment="正文内容/上下文") content = Column(Text, default="", comment="正文内容/上下文")
style_json = Column(Text, default="{}", comment="段落样式定义JSON") style_json = Column(Text, default="{}", comment="段落样式定义JSON")
is_table = Column(Boolean, default=False, comment="是否为表格") is_table = Column(Boolean, default=False, comment="是否为表格")
table_json = Column(Text, default="{}", comment="表格结构JSON") table_json = Column(Text, default="{}", comment="表格结构JSON")
edit_mode = Column(String(20), default="ai", comment="manual/ai") edit_mode = Column(String(20), default="manual", comment="manual/ai")
write_mode = Column(String(30), default="replace_section", comment="replace_section/append_after_heading/replace_heading_only")
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型") model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
need_prompt = Column(Boolean, default=True, comment="是否需要提示词") need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
prompt_text = Column(Text, default="", comment="预设提示词") prompt_text = Column(Text, default="", comment="预设提示词")
+30
View File
@@ -0,0 +1,30 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func
from database import Base
class TemplateBlock(Base):
__tablename__ = "template_blocks"
id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
source_paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True)
parent_block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True)
sort_index = Column(Integer, default=0, comment="排序")
block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable")
anchor_ref = Column(String(500), default="", comment="原始锚点引用")
title = Column(String(500), default="", comment="块标题")
content_json = Column(Text, default="{}", comment="块内容 JSON")
style_json = Column(Text, default="{}", comment="块样式 JSON")
edit_mode = Column(String(20), default="manual", comment="manual/ai")
placeholder_key = Column(String(120), default="", comment="AI 占位键")
variable_key = Column(String(120), default="", comment="变量键")
default_value = Column(Text, default="", comment="默认值")
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
prompt_text = Column(Text, default="", comment="预设提示词")
need_file = Column(Boolean, default=False, comment="是否需要上传参考文件")
file_note = Column(Text, default="", comment="参考文件说明")
output_format = Column(String(20), default="text", comment="text/table/mixed/chart")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+71 -1
View File
@@ -15,6 +15,7 @@ from models.document import Document
from models.generation_log import GenerationLog from models.generation_log import GenerationLog
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.template import Template from models.template import Template
from models.template_block import TemplateBlock
from services.document_export import export_document_bytes from services.document_export import export_document_bytes
from services.minio_client import ( from services.minio_client import (
download_object_bytes, download_object_bytes,
@@ -26,6 +27,46 @@ from services.minio_client import (
router = APIRouter() router = APIRouter()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
def _build_block_export_content(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
@router.get("/{document_id}/docx") @router.get("/{document_id}/docx")
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)): async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id) document = await db.get(Document, document_id)
@@ -39,17 +80,46 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
template_bucket, template_object = split_bucket_path(template.file_path) template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object) template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
block_result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template.id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = block_result.scalars().all()
log_result = await db.execute(
select(GenerationLog).where(GenerationLog.document_id == document_id)
)
generation_logs = log_result.scalars().all()
log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs}
logs = []
if blocks:
write_modes = _resolve_block_write_modes(blocks)
for block, write_mode in zip(blocks, write_modes):
generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block)
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": content,
}
)
else:
result = await db.execute( result = await db.execute(
select(GenerationLog, Paragraph) select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id) .join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id) .where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
) )
logs = []
for log, paragraph in result.all(): for log, paragraph in result.all():
logs.append( logs.append(
{ {
"anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title, "title": paragraph.title,
"write_mode": paragraph.write_mode,
"content": json.loads(log.content) if log.content else {"content": []}, "content": json.loads(log.content) if log.content else {"content": []},
} }
) )
+39
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import httpx
from database import get_db from database import get_db
from models.ai_model import AiModel from models.ai_model import AiModel
@@ -11,6 +12,11 @@ from services.security import decrypt_text, encrypt_text, mask_secret
router = APIRouter() router = APIRouter()
def _is_deepseek_model(model: AiModel) -> bool:
provider = (model.provider or "").strip().lower()
return provider == "deepseek"
def _serialize_model(model: AiModel) -> dict: def _serialize_model(model: AiModel) -> dict:
api_key = decrypt_text(model.api_key_encrypted) api_key = decrypt_text(model.api_key_encrypted)
return { return {
@@ -121,3 +127,36 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
message=str(error), message=str(error),
data={"id": model.id, "success": False}, data={"id": model.id, "success": False},
) )
@router.get("/{model_id}/balance")
async def get_model_balance(model_id: int, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
if not _is_deepseek_model(model):
raise HTTPException(status_code=400, detail="仅 DeepSeek 模型支持余额查询")
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise HTTPException(status_code=400, detail="模型 API Key 不可用")
try:
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
response = await client.get(
"https://api.deepseek.com/user/balance",
headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
)
response.raise_for_status()
payload = response.json()
except Exception as error:
raise HTTPException(status_code=400, detail=f"查询余额失败:{error}")
return Response(
data={
"id": model.id,
"provider": model.provider,
"is_available": payload.get("is_available", False),
"balance_infos": payload.get("balance_infos", []),
}
)
+360 -11
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import os import os
import tempfile import tempfile
import uuid import uuid
@@ -6,15 +7,19 @@ from datetime import datetime
from io import BytesIO from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import func, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
from database import get_db from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.template import Template from models.template import Template
from models.template_block import TemplateBlock
from schemas.schemas import Response, TemplateSave from schemas.schemas import Response, TemplateSave
from services.minio_client import minio_client from services.document_export import export_document_bytes
from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes
from services.template_parser import parse_template from services.template_parser import parse_template
router = APIRouter() router = APIRouter()
@@ -32,12 +37,14 @@ def _serialize_paragraph(paragraph: Paragraph) -> dict:
"id": paragraph.id, "id": paragraph.id,
"template_id": paragraph.template_id, "template_id": paragraph.template_id,
"sort_index": paragraph.sort_index, "sort_index": paragraph.sort_index,
"anchor_title": paragraph.anchor_title,
"title": paragraph.title, "title": paragraph.title,
"content": paragraph.content, "content": paragraph.content,
"style_json": paragraph.style_json, "style_json": paragraph.style_json,
"is_table": paragraph.is_table, "is_table": paragraph.is_table,
"table_json": paragraph.table_json, "table_json": paragraph.table_json,
"edit_mode": paragraph.edit_mode, "edit_mode": paragraph.edit_mode,
"write_mode": paragraph.write_mode,
"model_id": paragraph.model_id, "model_id": paragraph.model_id,
"need_prompt": paragraph.need_prompt, "need_prompt": paragraph.need_prompt,
"prompt_text": paragraph.prompt_text, "prompt_text": paragraph.prompt_text,
@@ -60,6 +67,270 @@ def _serialize_template(template: Template) -> dict:
} }
def _build_block_from_paragraph(paragraph: Paragraph) -> dict:
block_type = "heading" if paragraph.write_mode == "replace_heading_only" else ("ai_slot" if paragraph.edit_mode == "ai" else ("table" if paragraph.is_table else "text"))
content_json = json.dumps({
"text": paragraph.content or "",
"table": json.loads(paragraph.table_json or "{}") if paragraph.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": paragraph.id,
"parent_block_id": None,
"sort_index": paragraph.sort_index,
"block_type": block_type,
"anchor_ref": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"content_json": content_json,
"style_json": paragraph.style_json or "{}",
"edit_mode": paragraph.edit_mode,
"placeholder_key": "",
"variable_key": "",
"default_value": paragraph.content or "",
"model_id": paragraph.model_id,
"need_prompt": paragraph.need_prompt,
"prompt_text": paragraph.prompt_text,
"need_file": paragraph.need_file,
"file_note": paragraph.file_note,
"output_format": paragraph.output_format,
}
def _build_block_from_parsed_item(item, source_paragraph_id: int | None) -> dict:
content_json = json.dumps({
"text": item.content or "",
"table": json.loads(item.table_json or "{}") if item.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": source_paragraph_id,
"parent_block_id": None,
"sort_index": item.sort_index,
"block_type": item.block_type,
"anchor_ref": item.anchor_title or item.title,
"title": item.title,
"content_json": content_json,
"style_json": item.style_json or "{}",
"edit_mode": item.edit_mode,
"placeholder_key": item.placeholder_key,
"variable_key": item.variable_key,
"default_value": item.default_value,
"model_id": None,
"need_prompt": True,
"prompt_text": "",
"need_file": False,
"file_note": "",
"output_format": item.output_format,
}
def _serialize_block(block: TemplateBlock) -> dict:
try:
content_json = json.loads(block.content_json or "{}")
except Exception:
content_json = {}
return {
"id": block.id,
"template_id": block.template_id,
"source_paragraph_id": block.source_paragraph_id,
"parent_block_id": block.parent_block_id,
"sort_index": block.sort_index,
"block_type": block.block_type,
"anchor_ref": block.anchor_ref,
"title": block.title,
"content_json": content_json,
"style_json": block.style_json,
"edit_mode": block.edit_mode,
"placeholder_key": block.placeholder_key,
"variable_key": block.variable_key,
"default_value": block.default_value,
"model_id": block.model_id,
"need_prompt": block.need_prompt,
"prompt_text": block.prompt_text,
"need_file": block.need_file,
"file_note": block.file_note,
"output_format": block.output_format,
}
async def _load_blocks(db: AsyncSession, template_id: int) -> list[TemplateBlock]:
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
return result.scalars().all()
async def _sync_blocks_from_paragraphs(db: AsyncSession, template_id: int, paragraphs: list[Paragraph]):
existing_blocks = await _load_blocks(db, template_id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for paragraph in paragraphs:
block = TemplateBlock(template_id=template_id, **_build_block_from_paragraph(paragraph))
db.add(block)
block_rows.append(block)
await db.flush()
return block_rows
async def _save_blocks(
db: AsyncSession,
template_id: int,
blocks_payload,
):
existing_blocks = await _load_blocks(db, template_id)
block_map = {item.id: item for item in existing_blocks}
incoming_ids = {config.id for config in blocks_payload if config.id}
for block in existing_blocks:
if block.id not in incoming_ids:
await db.delete(block)
for index, config in enumerate(blocks_payload, start=1):
block = block_map.get(config.id) if config.id else None
if block is None:
block = TemplateBlock(template_id=template_id)
db.add(block)
block.source_paragraph_id = config.source_paragraph_id
block.parent_block_id = config.parent_block_id
block.sort_index = index
block.block_type = config.block_type
block.anchor_ref = config.anchor_ref or config.title
block.title = config.title
block.content_json = json.dumps(config.content_json or {}, ensure_ascii=False)
block.style_json = config.style_json or "{}"
block.edit_mode = config.edit_mode
block.placeholder_key = config.placeholder_key
block.variable_key = config.variable_key
block.default_value = config.default_value
block.model_id = config.model_id
block.need_prompt = config.need_prompt
block.prompt_text = config.prompt_text
block.need_file = config.need_file
block.file_note = config.file_note
block.output_format = config.output_format
await db.flush()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
async def _sync_paragraphs_from_blocks(db: AsyncSession, template_id: int) -> list[Paragraph]:
paragraph_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
existing_paragraphs = paragraph_result.scalars().all()
paragraph_map = {item.id: item for item in existing_paragraphs}
blocks = await _load_blocks(db, template_id)
write_modes = _resolve_block_write_modes(blocks)
kept_paragraph_ids: set[int] = set()
synced_rows: list[Paragraph] = []
for index, (block, write_mode) in enumerate(zip(blocks, write_modes), start=1):
paragraph = paragraph_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
if paragraph is None:
paragraph = Paragraph(template_id=template_id)
db.add(paragraph)
await db.flush()
paragraph.sort_index = index
paragraph.anchor_title = block.anchor_ref or block.title
paragraph.title = block.title
paragraph.content = _block_text_content(block)
paragraph.style_json = block.style_json or "{}"
paragraph.is_table = block.block_type == "table"
paragraph.table_json = json.dumps(_block_table_content(block), ensure_ascii=False) if paragraph.is_table else "{}"
paragraph.edit_mode = "ai" if block.block_type == "ai_slot" or block.edit_mode == "ai" else "manual"
paragraph.write_mode = write_mode
paragraph.model_id = block.model_id
paragraph.need_prompt = block.need_prompt
paragraph.prompt_text = block.prompt_text
paragraph.need_file = block.need_file
paragraph.file_note = block.file_note
paragraph.output_format = block.output_format
block.source_paragraph_id = paragraph.id
kept_paragraph_ids.add(paragraph.id)
synced_rows.append(paragraph)
for paragraph in existing_paragraphs:
if paragraph.id in kept_paragraph_ids:
continue
await db.execute(delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id))
await db.delete(paragraph)
await db.flush()
return synced_rows
def _build_export_content_from_block(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
async def _write_template_snapshot_to_docx(db: AsyncSession, template: Template):
blocks = await _load_blocks(db, template.id)
if not blocks:
return
write_modes = _resolve_block_write_modes(blocks)
logs = []
for block, write_mode in zip(blocks, write_modes):
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": _build_export_content_from_block(block),
}
)
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
await asyncio.to_thread(
upload_bytes,
template_bucket,
template_object,
exported_bytes,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
@router.get("") @router.get("")
async def list_templates( async def list_templates(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -96,9 +367,16 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
.where(Paragraph.template_id == template_id) .where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
) )
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()] paragraph_rows = result.scalars().all()
paragraphs = [_serialize_paragraph(item) for item in paragraph_rows]
block_rows = await _load_blocks(db, template_id)
if not block_rows and paragraph_rows:
block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows)
await db.commit()
blocks = [_serialize_block(item) for item in block_rows]
payload = _serialize_template(template) payload = _serialize_template(template)
payload["paragraphs"] = paragraphs payload["paragraphs"] = paragraphs
payload["blocks"] = blocks
return Response(data=payload) return Response(data=payload)
@@ -151,22 +429,41 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
paragraph = Paragraph( paragraph = Paragraph(
template_id=template.id, template_id=template.id,
sort_index=item.sort_index, sort_index=item.sort_index,
anchor_title=item.anchor_title,
title=item.title, title=item.title,
content=item.content, content=item.content,
style_json=item.style_json, style_json=item.style_json,
is_table=item.is_table, is_table=item.is_table,
table_json=item.table_json, table_json=item.table_json,
edit_mode=item.edit_mode,
write_mode=item.write_mode,
need_prompt=item.edit_mode == "ai",
output_format=item.output_format,
) )
db.add(paragraph) db.add(paragraph)
paragraph_rows.append(paragraph) paragraph_rows.append(paragraph)
await db.flush()
existing_blocks = await _load_blocks(db, template.id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for item, paragraph in zip(parsed_items, paragraph_rows):
block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id))
db.add(block)
block_rows.append(block)
await db.flush()
await db.commit() await db.commit()
await db.refresh(template) await db.refresh(template)
for paragraph in paragraph_rows: for paragraph in paragraph_rows:
await db.refresh(paragraph) await db.refresh(paragraph)
for block in block_rows:
await db.refresh(block)
payload = _serialize_template(template) payload = _serialize_template(template)
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows] payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
payload["blocks"] = [_serialize_block(item) for item in block_rows]
return Response(data=payload) return Response(data=payload)
@@ -180,16 +477,36 @@ async def save_template_paragraphs(
if template is None: if template is None:
raise HTTPException(status_code=404, detail="模板不存在") raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id)) result = await db.execute(
paragraph_map = {item.id: item for item in result.scalars().all()} select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
existing_paragraphs = result.scalars().all()
paragraph_map = {item.id: item for item in existing_paragraphs}
incoming_ids = {config.id for config in body.paragraphs if config.id}
for config in body.paragraphs: print(f"[SAVE] template_id={template_id}, incoming_ids={incoming_ids}, existing_ids={[p.id for p in existing_paragraphs]}")
paragraph = paragraph_map.get(config.id)
for paragraph in existing_paragraphs:
if paragraph.id not in incoming_ids:
print(f"[SAVE] Deleting paragraph id={paragraph.id} title={paragraph.title}")
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id)
)
await db.delete(paragraph)
for index, config in enumerate(body.paragraphs, start=1):
paragraph = paragraph_map.get(config.id) if config.id else None
if paragraph is None: if paragraph is None:
continue paragraph = Paragraph(template_id=template_id)
paragraph.sort_index = config.sort_index db.add(paragraph)
paragraph.sort_index = index
paragraph.anchor_title = config.anchor_title or config.title or paragraph.anchor_title
paragraph.title = config.title paragraph.title = config.title
paragraph.content = config.content
paragraph.edit_mode = config.edit_mode paragraph.edit_mode = config.edit_mode
paragraph.write_mode = config.write_mode
paragraph.model_id = config.model_id paragraph.model_id = config.model_id
paragraph.need_prompt = config.need_prompt paragraph.need_prompt = config.need_prompt
paragraph.prompt_text = config.prompt_text paragraph.prompt_text = config.prompt_text
@@ -197,8 +514,24 @@ async def save_template_paragraphs(
paragraph.file_note = config.file_note paragraph.file_note = config.file_note
paragraph.output_format = config.output_format paragraph.output_format = config.output_format
await db.flush()
refreshed_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
refreshed_paragraphs = refreshed_result.scalars().all()
if body.save_mode == "manual" and body.blocks:
await _save_blocks(db, template_id, body.blocks)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
else:
await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
template.paragraph_count = len(refreshed_paragraphs)
await _write_template_snapshot_to_docx(db, template)
await db.commit() await db.commit()
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)}) blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)]
return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks})
@router.delete("/{template_id}") @router.delete("/{template_id}")
@@ -208,7 +541,23 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=404, detail="模板不存在") raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id)) result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
for paragraph in result.scalars().all(): paragraphs_to_delete = result.scalars().all()
paragraph_ids = [p.id for p in paragraphs_to_delete]
doc_result = await db.execute(select(Document).where(Document.template_id == template_id))
documents_to_delete = doc_result.scalars().all()
if paragraph_ids:
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id.in_(paragraph_ids))
)
for document in documents_to_delete:
await db.execute(
delete(GenerationLog).where(GenerationLog.document_id == document.id)
)
await db.delete(document)
for paragraph in paragraphs_to_delete:
await db.delete(paragraph) await db.delete(paragraph)
file_path = template.file_path or "" file_path = template.file_path or ""
+28 -1
View File
@@ -31,8 +31,33 @@ class TemplateOut(BaseModel):
class ParagraphConfig(BaseModel): class ParagraphConfig(BaseModel):
id: int = 0 id: int = 0
sort_index: int = 0 sort_index: int = 0
anchor_title: str = ""
title: str = "" title: str = ""
edit_mode: str = "ai" content: str = ""
edit_mode: str = "manual"
write_mode: str = "replace_section"
model_id: Optional[int] = None
need_prompt: bool = True
prompt_text: str = ""
need_file: bool = False
file_note: str = ""
output_format: str = "text"
class TemplateBlockConfig(BaseModel):
id: int = 0
source_paragraph_id: Optional[int] = None
parent_block_id: Optional[int] = None
sort_index: int = 0
block_type: str = "text"
anchor_ref: str = ""
title: str = ""
content_json: dict[str, Any] = Field(default_factory=dict)
style_json: str = "{}"
edit_mode: str = "manual"
placeholder_key: str = ""
variable_key: str = ""
default_value: str = ""
model_id: Optional[int] = None model_id: Optional[int] = None
need_prompt: bool = True need_prompt: bool = True
prompt_text: str = "" prompt_text: str = ""
@@ -41,7 +66,9 @@ class ParagraphConfig(BaseModel):
output_format: str = "text" output_format: str = "text"
class TemplateSave(BaseModel): class TemplateSave(BaseModel):
save_mode: str = "paragraph"
paragraphs: list[ParagraphConfig] = [] paragraphs: list[ParagraphConfig] = []
blocks: list[TemplateBlockConfig] = []
# 模型 # 模型
class AiModelCreate(BaseModel): class AiModelCreate(BaseModel):
+251 -22
View File
@@ -32,6 +32,114 @@ def _delete_block(block):
parent.remove(element) parent.remove(element)
def _delete_heading_section(heading: Paragraph):
blocks = [heading]
current = heading._element.getnext()
while current is not None:
if isinstance(current, CT_P):
para = Paragraph(current, heading._parent)
if _is_heading(para):
break
blocks.append(para)
elif isinstance(current, CT_Tbl):
blocks.append(Table(current, heading._parent))
current = current.getnext()
for block in blocks:
_delete_block(block)
def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors: set[str]):
headings_to_remove: list[Paragraph] = []
found_first_heading = False
pre_heading_blocks: list = []
print(f"[EXPORT] referenced_anchors: {referenced_anchors}")
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block):
found_first_heading = True
text = block.text.strip()
if text not in referenced_anchors:
print(f"[EXPORT] Unreferenced heading found, will remove: '{text}'")
headings_to_remove.append(block)
elif not found_first_heading:
pre_heading_blocks.append(block)
for heading in headings_to_remove:
_delete_heading_section(heading)
if not referenced_anchors:
for block in pre_heading_blocks:
_delete_block(block)
def _ordered_unique_anchors(logs: list[dict]) -> list[str]:
ordered: list[str] = []
seen: set[str] = set()
for item in logs:
anchor = (item.get("anchor_title") or item.get("title") or "").strip()
if not anchor or anchor in seen:
continue
seen.add(anchor)
ordered.append(anchor)
return ordered
def _reorder_heading_sections(document: DocumentObject, ordered_anchors: list[str]):
body = document.element.body
elements = list(body.iterchildren())
pre_heading: list = []
sections: list[tuple[str, list]] = []
found_heading = False
index = 0
while index < len(elements):
child = elements[index]
if isinstance(child, CT_P):
paragraph = Paragraph(child, document)
if _is_heading(paragraph):
found_heading = True
anchor = paragraph.text.strip()
section_elements = [child]
index += 1
while index < len(elements):
current = elements[index]
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, document)
if _is_heading(current_paragraph):
break
section_elements.append(current)
index += 1
sections.append((anchor, section_elements))
continue
if not found_heading:
pre_heading.append(child)
index += 1
if not sections:
return
section_map: dict[str, list[list]] = {}
for anchor, section_elements in sections:
section_map.setdefault(anchor, []).append(section_elements)
all_section_elements = [element for _, section_elements in sections for element in section_elements]
for element in all_section_elements:
parent = element.getparent()
if parent is not None:
parent.remove(element)
sect_pr = None
for child in list(body.iterchildren()):
if not isinstance(child, (CT_P, CT_Tbl)):
sect_pr = child
break
for anchor in ordered_anchors:
for section_elements in section_map.pop(anchor, []):
for element in section_elements:
if sect_pr is not None:
sect_pr.addprevious(element)
else:
body.append(element)
def _clear_paragraph(paragraph: Paragraph): def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element element = paragraph._element
for child in list(element): for child in list(element):
@@ -56,6 +164,34 @@ def _copy_run_format(target_run, source_paragraph: Paragraph | None):
break break
def _extract_first_run_format(source_paragraph: Paragraph | None):
if source_paragraph is None:
return None
for source_run in source_paragraph.runs:
if source_run._element.rPr is not None:
return deepcopy(source_run._element.rPr)
return None
def _set_paragraph_text(
paragraph: Paragraph,
text: str,
style_name: str | None = None,
template_paragraph: Paragraph | None = None,
):
run_format = _extract_first_run_format(template_paragraph)
_clear_paragraph(paragraph)
if style_name:
try:
paragraph.style = style_name
except Exception:
pass
if text:
run = paragraph.add_run(text)
if run_format is not None:
run._element.insert(0, run_format)
def _append_paragraph_after( def _append_paragraph_after(
paragraph: Paragraph, paragraph: Paragraph,
text: str, text: str,
@@ -166,16 +302,12 @@ def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_e
return None return None
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict, after_element=None): def _collect_section_templates(heading: Paragraph):
heading = _find_heading_paragraph(document, heading_title, after_element)
if heading is None:
return after_element
first_body_style = None first_body_style = None
paragraph_template = None paragraph_template = None
table_template = None table_template = None
blocks = []
current = heading._element.getnext() current = heading._element.getnext()
blocks_to_remove = []
while current is not None: while current is not None:
if isinstance(current, CT_P): if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent) current_paragraph = Paragraph(current, heading._parent)
@@ -185,49 +317,146 @@ def _replace_section_content(document: DocumentObject, heading_title: str, conte
first_body_style = current_paragraph.style.name first_body_style = current_paragraph.style.name
if paragraph_template is None: if paragraph_template is None:
paragraph_template = current_paragraph paragraph_template = current_paragraph
blocks_to_remove.append(current_paragraph) blocks.append(current_paragraph)
elif isinstance(current, CT_Tbl): elif isinstance(current, CT_Tbl):
current_table = Table(current, heading._parent) current_table = Table(current, heading._parent)
if table_template is None: if table_template is None:
table_template = current_table table_template = current_table
blocks_to_remove.append(current_table) blocks.append(current_table)
current = current.getnext() current = current.getnext()
return first_body_style, paragraph_template, table_template, blocks
for block in blocks_to_remove:
_delete_block(block)
insert_after = heading def _insert_content_after(
insert_after: Paragraph,
content: dict,
first_body_style: str | None,
paragraph_template: Paragraph | None,
table_template: Table | None,
):
current_anchor: Paragraph = insert_after
content_blocks = content.get("content", []) content_blocks = content.get("content", [])
for block in content_blocks: for block in content_blocks:
block_type = block.get("type") block_type = block.get("type")
if block_type == "table": if block_type == "table":
rows = [list(row) for row in block.get("rows", [])] rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or [] headers = block.get("headers") or []
table = _append_table_after(insert_after, rows, headers, table_template) table = _append_table_after(current_anchor, rows, headers, table_template)
insert_after = _append_empty_paragraph_after_table(table, first_body_style) current_anchor = _append_empty_paragraph_after_table(table, first_body_style)
else: else:
text = block.get("text", "") text = block.get("text", "")
text_parts = [item for item in text.split("\n") if item] or [text] text_parts = [item for item in text.split("\n") if item] or [text]
for text_part in text_parts: for text_part in text_parts:
insert_after = _append_paragraph_after( current_anchor = _append_paragraph_after(
insert_after, current_anchor,
text_part, text_part,
first_body_style, first_body_style,
paragraph_template, paragraph_template,
) )
return current_anchor
def _replace_section_content(
document: DocumentObject,
anchor_title: str,
target_title: str,
content: dict,
write_mode: str,
after_element=None,
):
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if write_mode == "replace_heading_only":
return heading._element
if write_mode == "replace_section":
for block in blocks_to_remove:
_delete_block(block)
_insert_content_after(
heading,
content,
first_body_style,
paragraph_template,
table_template,
)
return heading._element
def _group_logs(logs: list[dict]) -> list[list[dict]]:
groups: list[list[dict]] = []
for item in logs:
anchor_title = item.get("anchor_title") or item.get("title") or ""
if not groups:
groups.append([item])
continue
last_group = groups[-1]
last_anchor = last_group[0].get("anchor_title") or last_group[0].get("title") or ""
if anchor_title == last_anchor:
last_group.append(item)
else:
groups.append([item])
return groups
def _replace_section_group(
document: DocumentObject,
items: list[dict],
after_element=None,
):
first_item = items[0]
anchor_title = first_item.get("anchor_title") or first_item.get("title") or ""
target_title = first_item.get("title") or anchor_title
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if len(items) == 1 and first_item.get("write_mode") == "replace_heading_only":
return heading._element
preserve_existing = len(items) == 1 and first_item.get("write_mode") == "append_after_heading"
if not preserve_existing:
for block in blocks_to_remove:
_delete_block(block)
current_anchor = heading
for item in items:
current_anchor = _insert_content_after(
current_anchor,
item.get("content") or {"content": []},
first_body_style,
paragraph_template,
table_template,
)
return heading._element return heading._element
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes: def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes)) document = Document(BytesIO(template_bytes))
last_heading_element = None ordered_anchors = _ordered_unique_anchors(logs)
_reorder_heading_sections(document, ordered_anchors)
referenced_anchors: set[str] = set()
for item in logs: for item in logs:
last_heading_element = _replace_section_content( for key in ("anchor_title", "title"):
document, val = (item.get(key) or "").strip()
item["title"], if val:
item["content"], referenced_anchors.add(val)
last_heading_element,
) print(f"[EXPORT] logs count={len(logs)}, anchor_titles={[(l.get('anchor_title'), l.get('title')) for l in logs]}")
last_heading_element = None
for group in _group_logs(logs):
last_heading_element = _replace_section_group(document, group, last_heading_element)
_remove_unreferenced_headings(document, referenced_anchors)
output = BytesIO() output = BytesIO()
document.save(output) document.save(output)
+88 -22
View File
@@ -1,4 +1,5 @@
import json import json
import re
from collections.abc import Iterator from collections.abc import Iterator
from dataclasses import dataclass from dataclasses import dataclass
@@ -15,11 +16,19 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH
@dataclass @dataclass
class ParsedParagraph: class ParsedParagraph:
sort_index: int sort_index: int
anchor_title: str
title: str title: str
content: str content: str
style_json: str style_json: str
is_table: bool is_table: bool
table_json: str table_json: str
write_mode: str
block_type: str = "text"
placeholder_key: str = ""
variable_key: str = ""
default_value: str = ""
edit_mode: str = "manual"
output_format: str = "text"
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]: def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
@@ -157,11 +166,35 @@ def _extract_table_data(table: Table) -> dict:
} }
PLACEHOLDER_PATTERN = re.compile(r"^\{\{\s*([a-zA-Z0-9_\-\.]+)\s*\}\}$")
def _build_block_title(text: str, fallback: str) -> str:
normalized = " ".join((text or "").split())
if not normalized:
return fallback
return normalized[:24] + ("..." if len(normalized) > 24 else "")
def _classify_placeholder(text: str) -> tuple[str, str, str]:
matched = PLACEHOLDER_PATTERN.match(text.strip())
if not matched:
return "text", "", ""
key = matched.group(1)
lowered = key.lower()
if any(token in lowered for token in ("summary", "opening", "section", "content", "analysis")):
return "ai_slot", key, ""
return "variable", "", key
def parse_template(file_path: str) -> list[ParsedParagraph]: def parse_template(file_path: str) -> list[ParsedParagraph]:
document = Document(file_path) document = Document(file_path)
parsed: list[ParsedParagraph] = [] parsed: list[ParsedParagraph] = []
current_item: ParsedParagraph | None = None current_heading: str | None = None
current_heading_style_json = "{}"
loose_table_count = 0 loose_table_count = 0
body_block_count = 0
preface_count = 0
for block in _iter_block_items(document): for block in _iter_block_items(document):
if isinstance(block, Paragraph): if isinstance(block, Paragraph):
@@ -171,46 +204,79 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
level = _heading_level(block.style.name if block.style is not None else "") level = _heading_level(block.style.name if block.style is not None else "")
if level is not None: if level is not None:
current_item = ParsedParagraph( current_heading = text
current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False)
body_block_count = 0
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1, sort_index=len(parsed) + 1,
anchor_title=text,
title=text, title=text,
content="", content="",
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False), style_json=current_heading_style_json,
is_table=False, is_table=False,
table_json="{}", table_json="{}",
) write_mode="replace_heading_only",
parsed.append(current_item) block_type="heading",
edit_mode="manual",
output_format="text",
))
continue continue
if current_item is None: block_type, placeholder_key, variable_key = _classify_placeholder(text)
current_item = ParsedParagraph( if current_heading is None:
preface_count += 1
anchor_title = f"文档起始_{preface_count}"
title = _build_block_title(text, anchor_title)
write_mode = "replace_section"
else:
body_block_count += 1
anchor_title = current_heading
title = _build_block_title(text, f"{current_heading}-正文{body_block_count}")
write_mode = "append_after_heading"
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1, sort_index=len(parsed) + 1,
title="未命名段落", anchor_title=anchor_title,
title=title,
content=text, content=text,
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False), style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
is_table=False, is_table=False,
table_json="{}", table_json="{}",
) write_mode=write_mode,
parsed.append(current_item) block_type=block_type,
else: placeholder_key=placeholder_key,
current_item.content = "\n".join(filter(None, [current_item.content, text])) variable_key=variable_key,
default_value="" if variable_key else text,
edit_mode="ai" if block_type == "ai_slot" else "manual",
output_format="text",
))
else: else:
table_data = _extract_table_data(block) table_data = _extract_table_data(block)
table_text = f"[表格] {table_data['rows']}{table_data['cols']}" table_text = f"[表格] {table_data['rows']}{table_data['cols']}"
if current_item is None: if current_heading is None:
loose_table_count += 1 loose_table_count += 1
current_item = ParsedParagraph( anchor_title = f"表格_{loose_table_count}"
title = anchor_title
write_mode = "replace_section"
else:
body_block_count += 1
anchor_title = current_heading
title = f"{current_heading}-表格{body_block_count}"
write_mode = "append_after_heading"
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1, sort_index=len(parsed) + 1,
title=f"表格_{loose_table_count}", anchor_title=anchor_title,
title=title,
content=table_text, content=table_text,
style_json="{}", style_json=current_heading_style_json if current_heading else "{}",
is_table=True, is_table=True,
table_json=json.dumps(table_data, ensure_ascii=False), table_json=json.dumps(table_data, ensure_ascii=False),
) write_mode=write_mode,
parsed.append(current_item) block_type="table",
else: default_value=table_text,
current_item.is_table = True edit_mode="manual",
current_item.table_json = json.dumps(table_data, ensure_ascii=False) output_format="table",
current_item.content = "\n".join(filter(None, [current_item.content, table_text])) ))
return parsed return parsed
+18
View File
@@ -219,3 +219,21 @@
4. 调整导出时的表格回写方式,优先克隆模板原有表格结构并填充新数据,尽量保留表格外观和基础样式。 4. 调整导出时的表格回写方式,优先克隆模板原有表格结构并填充新数据,尽量保留表格外观和基础样式。
5. 执行后端语法检查,确认导出服务改动稳定。 5. 执行后端语法检查,确认导出服务改动稳定。
- **执行结果**: 当前导出链路已改为“尽量复用模板原始段落/表格样式后写入 AI 内容”,比此前的新建空白内容块方式更接近原模板样式,也更容易把 AI 结果正确写回文档。 - **执行结果**: 当前导出链路已改为“尽量复用模板原始段落/表格样式后写入 AI 内容”,比此前的新建空白内容块方式更接近原模板样式,也更容易把 AI 结果正确写回文档。
## 会话 ID: local-20260702202424
- [2026-07-02 20:24:24]
- **执行原因**: 用户希望用极简一句话概括当前项目功能状态,并说明明天的工作重点。
- **执行过程**:
1. 基于当前已完成能力,压缩总结项目现状。
2. 提炼明日优先事项,聚焦导出准确性与样式保真。
- **执行结果**: 已形成简短状态说明,可作为明日继续开发的工作摘要。
## 会话 ID: local-20260702202807
- [2026-07-02 20:28:07]
- **执行原因**: 用户希望将任务详情页改为“上 + 左右”结构,左侧显示段落,右侧显示生成结果预览。
- **执行过程**:
1. 重构任务详情页布局,将页面调整为顶部任务概览、下方左右分栏的结构。
2. 左侧增加段落列表与段落状态展示,并保留附件映射信息。
3. 右侧改为当前选中段落的结果预览区域,展示段落标题、状态、文件要求和生成内容。
4. 执行前端类型检查,确认本轮布局调整稳定。
- **执行结果**: 当前任务详情页已改为“上 + 左右”结构,用户可在左侧切换段落,在右侧查看对应生成结果预览。
+164
View File
@@ -0,0 +1,164 @@
# 任务执行摘要
## 会话 ID: local-20260703092828
- [2026-07-03 09:28:28]
- **执行原因**: 用户要求提交当前已完成的预览页布局调整代码。
- **执行过程**:
1. 检查当前工作区改动,确认仅包含任务详情页左右布局调整和对应任务记录。
2. 整理并暂存相关文件,排除未跟踪的原型目录。
3. 准备使用中文提交信息完成本次代码提交。
- **执行结果**: 当前改动已整理完成并准备提交,包含任务详情页“上 + 左右”结构调整。
## 会话 ID: local-20260703092933
- [2026-07-03 09:29:33]
- **执行原因**: 用户希望预览页左右两栏固定在顶层显示,不随页面整体滚动,而是在局部区域内滚动。
- **执行过程**:
1. 调整任务详情页根容器高度和溢出策略,禁止页面整体滚动。
2. 为左右分栏区域设置固定可用高度和内部滚动容器,使左侧段落列表、右侧预览内容各自滚动。
3. 执行前端类型检查,确认布局调整稳定。
- **执行结果**: 当前预览页已改为页面整体固定、左右两栏局部滚动的显示方式,顶部信息区域保持固定可见。
## 会话 ID: local-20260703093617
- [2026-07-03 09:36:17]
- **执行原因**: 用户询问当前模板导入时如何识别 `.doc/.docx` 文件中的段落边界。
- **执行过程**:
1. 定位模板上传入口与解析服务,确认模板导入的实际文件格式限制。
2. 阅读 `template_parser` 实现,核对标题识别、正文归并和表格归属逻辑。
3. 结合设计文档整理当前段落识别规则与边界行为,准备向用户说明。
- **执行结果**: 已确认当前模板导入仅支持 `.docx`;段落边界基于 Word 内置 `Heading` 样式识别,普通正文归并到最近标题下,表格归属最近段落或独立成段。
## 会话 ID: local-20260703093913
- [2026-07-03 09:39:13]
- **执行原因**: 用户进一步询问特殊模板场景下,是否支持只修改标题、不修改标题下固定内容,以及是否可以手动调整段落与模板内容。
- **执行过程**:
1. 核对模板编辑页界面与保存逻辑,确认当前可编辑字段范围。
2. 检查后端模板保存 schema,确认是否支持手动拆段、合段或正文内容持久化编辑。
3. 基于现状整理可行产品方案,包括自动识别候选标题与人工微调两类路径。
- **执行结果**: 已确认当前系统支持修改段落标题和生成配置,但暂不支持手动拆段/合段,也不支持在系统内直接编辑模板正文;可通过新增“标题仅替换”模式与手动段落调整能力满足该场景。
## 会话 ID: local-20260703094127
- [2026-07-03 09:41:27]
- **执行原因**: 用户希望模板编辑阶段支持人工直接编辑模板内容,并讨论是否应改为在标题下方占位填充而非整段替换,同时询问在线文档编辑实现思路。
- **执行过程**:
1. 检查当前导出实现,确认模板内容替换的实际粒度与边界。
2. 结合现有解析和导出方式,评估“段落模式”与“手动编辑 Word 模式”的双模式方案。
3. 查阅腾讯文档相关公开资料,整理在线文档通常采用的协同编辑架构和导入导出模型。
- **执行结果**: 已确认当前导出为标题间整段替换;建议新增“手动编辑模板内容”与“占位填充”能力,并采用结构化文档模型而非直接把 `.docx` 当在线编辑源格式处理。
## 会话 ID: local-20260703094614
- [2026-07-03 09:46:14]
- **执行原因**: 用户确认实施模板编辑增强,要求支持“段落配置 / 手动编辑模板”切换,并改进 AI 内容写回 Word 的方式。
- **执行过程**:
1. 扩展段落数据结构,新增原始标题锚点和写入方式字段,并补充数据库自动迁移逻辑。
2. 改造模板编辑页,增加“段落配置 / 手动编辑模板”切换,支持直接编辑标题、正文和写入方式。
3. 改造导出逻辑,支持仅替换标题、标题下插入内容、替换整段三种写入模式,同时保持原始标题定位能力。
4. 执行后端编译检查与前端 `npm run build`,确认本轮改动可正常通过。
- **执行结果**: 模板编辑页现已支持结构化手动编辑;导出时可按段落配置选择“整段替换 / 标题下插入 / 仅改标题”,能更好处理固定正文与 AI 生成内容并存的模板场景。
## 会话 ID: local-20260703094757
- [2026-07-03 09:47:57]
- **执行原因**: 用户需要本轮模板编辑增强对应的数据库增量 SQL。
- **执行过程**:
1. 对照本轮后端模型与初始化脚本,确认实际新增的持久化字段。
2. 整理兼容现有数据的 `ALTER TABLE` 与回填语句,确保旧模板可正常迁移。
3. 记录增量说明,便于后续环境执行与核验。
- **执行结果**: 已输出可直接执行的 MySQL 增量 SQL,包含 `paragraphs.anchor_title``paragraphs.write_mode` 两个新字段及历史数据回填语句。
## 会话 ID: local-20260703095307
- [2026-07-03 09:53:07]
- **执行原因**: 用户质疑当前模板编辑仍像段落配置而非在线 Word 编辑,并询问是否可以手动插入新的 AI 段落。
- **执行过程**:
1. 重新核对模板解析逻辑,确认当前仍以 Word `Heading` 样式作为段落边界。
2. 核对导出写回逻辑,确认当前是围绕已识别标题区块进行替换或插入,而不是对文档块级结构进行自由编辑。
3. 基于用户反馈梳理下一阶段应改造为“块级在线文档编辑 + AI 占位段落”的方向。
- **执行结果**: 已明确当前系统还不支持像腾讯文档那样手动插入新段落块;若要满足该诉求,应将模板编辑从“段落配置”升级为“文档块编辑”,支持新增 AI 段落占位、拆分正文块与固定块。
## 会话 ID: local-20260703095628
- [2026-07-03 09:56:28]
- **执行原因**: 用户要求继续推进,支持在模板中手动拆块并插入 AI 段落。
- **执行过程**:
1. 扩展模板保存接口,支持创建新块、删除旧块,并按当前编辑顺序重排 `sort_index`
2. 改造导出逻辑,按连续的 `anchor_title` 分组写回同一节内容,使一个原标题下可挂多个手动/AI 块。
3. 改造模板编辑页,在手动编辑模式下新增“在后面新增固定块 / AI 块 / 删除当前块”操作。
4. 执行后端编译检查与前端 `npm run build`,确认新增块编辑能力可正常通过构建。
- **执行结果**: 当前模板编辑已支持把同一原标题下的内容手动拆成多个块,并插入新的 AI 块或固定块;导出时会按块顺序写回同一节内容,较之前更接近在线文档式的人工干预流程。
## 会话 ID: local-20260703095940
- [2026-07-03 09:59:40]
- **执行原因**: 用户要求模板默认导入后全部识别为人工手动,而不是 AI 生成。
- **执行过程**:
1. 调整段落模型默认值与初始化脚本默认值,将 `edit_mode` 默认改为 `manual`
2. 调整模板上传落库逻辑,显式将新导入段落设置为 `manual`,避免受历史数据库默认值影响。
3. 执行后端编译检查,确认默认值调整未引入语法或依赖问题。
- **执行结果**: 新导入模板中的识别段落现在默认全部为人工手动;如需 AI 生成,需要用户在模板编辑页中显式切换对应块为 AI 模式。
## 会话 ID: local-20260703100253
- [2026-07-03 10:02:53]
- **执行原因**: 用户反馈模板编辑页 `doc-edit-page` 没有随内容高度增长,导致内容超出纸张容器显示。
- **执行过程**:
1. 检查编辑页中部滚动区与纸张容器的 flex 布局关系,定位到默认纵向拉伸导致纸张高度被固定。
2. 调整 `center-scroll` 的对齐方式为顶部对齐,并禁止 `doc-edit-page` 在 flex 布局中被压缩。
3. 执行前端 `npm run build`,确认样式修复后页面仍可正常构建。
- **执行结果**: 模板编辑页中的纸张容器现在会按内容自然增高,不再因为父级 flex 拉伸而出现内容超出容器显示的问题。
## 会话 ID: local-20260703100656
- [2026-07-03 10:06:56]
- **执行原因**: 用户询问执行生成页是否支持从历史文件中复用已上传附件。
- **执行过程**:
1. 检查 `GeneratePage.vue` 的文件上传区域与状态管理逻辑,确认当前前端入口能力。
2. 对照 `generateApi` 与后端 `reference-files` 接口,确认后端已有历史文件查询能力是否被生成页接入。
3. 整理当前支持范围与缺口,准备向用户说明现状与后续改造方向。
- **执行结果**: 已确认生成页当前仅支持新上传文件,不支持在页面内选择历史文件复用;后端已有历史文件接口,但该页尚未接入对应 UI 与选择逻辑。
## 会话 ID: local-20260703104125
- [2026-07-03 10:41:25]
- **执行原因**: 用户建议参考模板编辑中的历史文件复用能力,并封装成通用组件供执行生成页复用。
- **执行过程**:
1. 抽离公共 `ReferenceFileSelector` 组件,统一封装新上传、历史文件搜索复用、已选文件展示与移除逻辑。
2. 将模板编辑页段落测试弹窗接入该组件,替换原有分散的上传与历史文件逻辑。
3. 将执行生成页接入同一组件,使每个需上传文件的段落同时支持上传新文件和选择历史文件。
4. 执行前端 `npm run build`,确认组件复用后页面构建正常。
- **执行结果**: 当前模板编辑测试弹窗与执行生成页已共用同一套文件选择组件;执行生成页现已支持历史文件复用,不再局限于本次新上传。
## 会话 ID: local-20260703104741
- [2026-07-03 10:47:41]
- **执行原因**: 用户希望模型管理页支持厂商预设、DeepSeek 余额查看,以及将测试按钮改为带转圈的刷新式提示。
- **执行过程**:
1. 扩展模型前端 API 与 store,新增余额查询调用。
2. 在后端模型路由中新增 DeepSeek 余额查询接口,并基于已保存的 API Key 调用官方余额接口。
3. 改造模型管理页,新增 `DeepSeek / 自定义` 厂商预设、DeepSeek 余额展示与查询按钮。
4. 将测试按钮改为带 loading 的“刷新测试”,结果改为自动消失的轻提示,不再使用需要手动关闭的弹窗。
5. 执行后端编译检查与前端 `npm run build`,确认改动可正常构建。
- **执行结果**: 模型管理页现已支持厂商预设;DeepSeek 模型可直接查询余额;测试按钮改为更轻量的刷新式交互,点击后会转圈并自动提示结果。
## 会话 ID: local-20260703104944
- [2026-07-03 10:49:44]
- **执行原因**: 用户发现将厂商改成自定义后,页面仍被识别为 DeepSeek,且自定义厂商也出现余额查询能力。
- **执行过程**:
1. 排查模型管理页与后端余额接口的 DeepSeek 判定条件。
2. 将判定逻辑从“厂商或 endpoint 命中 DeepSeek”收紧为“仅当 provider 明确为 DeepSeek 时才视为 DeepSeek 模型”。
3. 执行后端编译检查与前端 `npm run build`,确认修正后功能正常。
- **执行结果**: 当前只有在厂商明确设置为 `DeepSeek` 时,页面才会显示 DeepSeek 预设状态与余额查询按钮;改为自定义厂商后不会再被 endpoint 误判为 DeepSeek。
## 会话 ID: local-20260703111410
- [2026-07-03 11:14:10]
- **执行原因**: 用户要求将当前阶段改动提交到 Git。
- **执行过程**:
1. 检查工作区变更,确认本轮后端、前端与任务记录文件可一并提交。
2. 排除未跟踪的原型目录,仅暂存本次功能实现相关文件。
3. 使用中文提交信息完成本次代码提交。
- **执行结果**: 当前模板编辑、历史文件复用、模型管理增强等改动已整理完成,准备提交到本地 Git 历史。
## 会话 ID: local-20260703154400
- [2026-07-03 15:44:00]
- **执行原因**: 用户需要在模板编辑器中支持段落删除与移动排序功能,同时修复删除后导出 Word 仍有残留段落的问题。
- **执行过程**:
1. 改造前端 TemplateEditor.vue,在左侧段落列表、段落配置预览区、手动编辑区三处新增上移/下移/删除按钮,hover 时显示。
2. 新增 canMoveUp/canMoveDown/moveUp/moveDown/handleDeleteParagraph 函数,移动用 splice 交换后 normalizeSortIndex,删除走 Modal.confirm 确认框。
3. 修正 canDeleteBlock 判定逻辑(原为同 anchor_title 下有 >1 个段落才能删,改为总段落数 >1 即可删)。
4. 新增 autoSaveParagraphs 函数,移动/删除后直接调 API 自动保存,同步 store 状态。
5. 修复后端 PUT /{template_id}/paragraphs 接口:删除段落前先级联删除 generation_logs,避免 FK 约束报错。
6. 修复后端导出逻辑 document_export.py:新增 _delete_heading_section 和 _remove_unreferenced_headings 函数,导出时清理未被 generation_logs 引用的标题段落,确保已删段落的原标题和内容不会残留在 Word 中。
7. 修复后端 DELETE /{template_id} 接口:级联清理 generation_logs、documents、paragraphs,解决删除整个模板时的 FK 约束失败。
8. 添加前后端调试日志辅助排查,确认功能正常后提交代码。
- **执行结果**: 段落删除与移动排序功能完整实现,已删段落在生成导出后不再残留,模板删除 FK 约束已修复。提交 commit 1369d87。
+152
View File
@@ -0,0 +1,152 @@
# 任务执行摘要
## 会话 ID: local-20260705193723
- [2026-07-05 19:37:23]
- **执行原因**: 用户询问“执行生成”功能当前是如何实现文档导出的,希望梳理从提交生成到导出 Word 的实际代码链路。
- **执行过程**:
1. 检查执行生成页 `GeneratePage.vue`,确认前端提交任务的入口与跳转路径。
2. 检查后端 `generate.py``generation_runtime.py`,确认生成任务创建、后台执行和段落结果落库方式。
3. 检查预览页 `PreviewEdit.vue`、导出 API `export.py``document_export.py`,确认导出 Word 的触发点、模板读取方式和内容写回逻辑。
- **执行结果**: 已确认“执行生成”页本身只负责创建后台生成任务;真正的 DOCX 导出发生在任务详情页点击“导出 Word”后,由后端从 MinIO 拉取原始模板、读取 `generation_logs`、按标题锚点写回内容,再上传导出文件并返回预签名下载链接。
## 会话 ID: local-20260705194530
- [2026-07-05 19:45:30]
- **执行原因**: 用户要求先输出“在线编辑 + AI 选区 + 导出重构”的整体方案、任务清单,并评估是否可行实现。
- **执行过程**:
1. 参考现有 `03-任务拆解清单.md``04-后续迭代任务拆解清单.md` 的拆解风格,整理适合当前项目的阶段方案。
2. 结合现有模板解析、模板编辑与导出实现,识别当前架构与目标方案之间的差距。
3. 输出分阶段实施建议,并评估技术可行性、实现难点与优先级。
- **执行结果**: 已形成可执行的重构方案:以“块级在线编辑 + AI 选区标记 + 模板源同步写回 + 精确导出”为主线,建议分为编辑器重构、块模型升级、写回引擎重构、生成链路适配与联调验收五个阶段推进;整体可行,但不建议一步直追腾讯文档式完整协同编辑。
## 会话 ID: local-20260705195210
- [2026-07-05 19:52:10]
- **执行原因**: 用户要求将该方案整理成与 `03-任务拆解清单.md` 同风格的正式任务清单文档。
- **执行过程**:
1. 对照 `03-任务拆解清单.md` 的结构,统一“阶段 -> 子模块 -> 勾选项 -> 交付物”的表达方式。
2. 将“在线编辑模式、AI 选区模式、模板源同步写回、导出引擎重构”等内容拆成可执行任务项。
3. 新增正式文档到 `docs/需求与设计/`,便于后续按清单逐步实施。
- **执行结果**: 已新增 `05-模板在线编辑重构任务拆解清单.md`,内容结构与 `03-任务拆解清单.md` 保持一致,可直接作为后续实施清单使用。
## 会话 ID: local-20260705200540
- [2026-07-05 20:05:40]
- **执行原因**: 用户要求开始按 `05-模板在线编辑重构任务拆解清单.md` 落实代码。
- **执行过程**:
1. 先从第一阶段“数据模型升级”入手,新增 `template_blocks` 数据模型、初始化 DDL 和启动时自动建表逻辑。
2. 改造模板接口返回结构,在保留旧 `paragraphs` 兼容的同时,新增 `blocks` 序列化输出。
3. 改造模板上传与保存逻辑,使新上传模板会自动同步生成块数据;保存段落时若前端尚未显式传块,则自动从段落重建块,确保兼容过渡。
4. 更新前端 `types/store`,接入 `blocks` 字段;补充本轮增量 SQL 文档,并将任务清单中已完成的数据模型项勾选。
5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit` 校验,确认本轮改动通过编译检查。
- **执行结果**: 已完成“模板在线编辑重构”第一阶段中的数据模型升级基础设施,系统现已具备 `template_blocks` 持久化能力,并能在不破坏现有模板编辑流程的前提下为后续块级编辑器改造提供后端承载。
## 会话 ID: local-20260705202018
- [2026-07-05 20:20:18]
- **执行原因**: 用户要求继续推进模板在线编辑重构,优先落实第二阶段的模板块级解析能力。
- **执行过程**:
1. 改造 `template_parser.py`,将导入解析从“按 Heading 聚合大段”调整为“标题块 + 正文块 + 表格块”的细粒度块流。
2. 为解析结果补充块元信息,包括 `block_type``placeholder_key``variable_key``edit_mode``output_format`,并对显式 `{{ xxx }}` 占位做初步分类。
3. 改造模板上传逻辑,创建 `Paragraph` 时同步写入更细粒度的导入结果,并基于解析结果直接生成 `template_blocks`
4. 更新任务清单勾选状态,标记已完成的“标题块/正文块/表格块/块级 JSON 结构”等子项。
5. 再次执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认本轮解析器重构未引入编译错误。
- **执行结果**: 当前模板导入阶段已具备初步块级解析能力,导入后的结构不再只是一整段聚合文本,而是更接近后续在线编辑所需的块流模型,为第一页内容拆分和 AI 选区改造打下了基础。
## 会话 ID: local-20260705203240
- [2026-07-05 20:32:40]
- **执行原因**: 用户继续推进,要求将模板编辑页的“手动编辑模板”模式真正切换到块模型上。
- **执行过程**:
1. 改造 `TemplateEditor.vue` 的选择与渲染逻辑,引入 `blocks` 本地状态以及 `currentItems / selectedConfigItem` 计算属性。
2. 保留原“段落配置”模式兼容现有流程,同时让“手动编辑模板”模式改为基于 `blocks` 渲染左侧列表、中间编辑画布和右侧配置区。
3. 改造移动、删除、插入、自动保存与保存模板逻辑,使其在手动模式下可针对块结构生效,并将 `blocks` 一并提交到模板保存接口。
4. 为块编辑模式补充块类型标签、变量键/AI 占位键配置,以及块级测试时对 `source_paragraph_id` 的兼容校验。
5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认本轮页面改造与保存链路通过编译检查。
- **执行结果**: 模板编辑页当前已实现“段落模式 / 块模式”双轨运行;其中手动编辑模板模式已开始基于 `template_blocks` 工作,块列表、块画布与右侧配置面板能够联动,为下一步实现 AI 选区模式奠定了前端基础。
## 会话 ID: local-20260705204055
- [2026-07-05 20:40:55]
- **执行原因**: 用户反馈模板编辑页保存时报 `PUT /templates/{id}/paragraphs 500`,并且进入模板编辑时页面空白。
- **执行过程**:
1. 根据报错 SQL 定位到 `template_blocks.content_json``TEXT` 字段,但上传/同步块时误将 Python `dict` 直接写入数据库。
2. 修正模板块构建逻辑,在 `_build_block_from_paragraph``_build_block_from_parsed_item` 中统一将 `content_json` 序列化为 JSON 字符串。
3. 为旧模板补充兼容逻辑:读取模板详情时若尚无 `blocks` 数据,则自动根据现有 `paragraphs` 重建块数据并写回数据库。
4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复后无编译错误。
- **执行结果**: 已修复模板保存时的 500 报错来源;老模板在没有 `blocks` 数据时也会自动补齐,模板编辑页不应再因块数据缺失而显示空白。
## 会话 ID: local-20260705204820
- [2026-07-05 20:48:20]
- **执行原因**: 用户指出当前改造“页面上完全没区别”,要求明确已完成内容与可见效果之间的差距。
- **执行过程**:
1. 复盘本轮已落地内容,区分数据层/接口层改造与实际可见的界面改造。
2. 明确当前仍未完成的“可见功能”项,包括 AI 选区、块级专属工具条、变量块突出展示、块级样式差异等。
3. 准备将后续工作重心从底层铺设切换到用户可见的编辑体验改造。
- **执行结果**: 已确认当前阶段主要完成了块模型、解析器与保存链路等基础设施,前端交互层仍缺少足够显著的视觉与操作变化;后续需优先补齐用户可感知的块级编辑与 AI 选区功能。
## 会话 ID: local-20260705205630
- [2026-07-05 20:56:30]
- **执行原因**: 用户要求继续实施,并优先看到模板编辑页中“手动编辑模板”模式的可见变化。
- **执行过程**:
1. 改造 `TemplateEditor.vue` 顶部工具条,在手动模式下新增“新增标题块 / 正文块 / AI 块 / 变量块”操作入口。
2. 改造中间块画布的卡片样式,为标题块、正文块、AI 块、变量块、表格块提供不同的边框、背景和标识信息。
3. 调整左侧列表标签与右侧配置项,使块类型、AI 占位键、变量键、固定块信息在界面上可直接感知。
4. 补齐块模式下新增、删除、上移、下移的界面交互,并修正新增块时误写入旧 `paragraphs` 数组的问题。
5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认可见层改造通过编译检查。
- **执行结果**: 模板编辑页的手动模式现在已有明显的块级编辑视觉效果与块工具条,页面不再只是“底层换数据源但外观几乎不变”;用户可直接看到并操作标题块、正文块、AI 块和变量块。
## 会话 ID: local-20260705210520
- [2026-07-05 21:05:20]
- **执行原因**: 用户追问当前是否真正完成“在线编辑文档效果”、执行生成为何未按模板段落顺序导出,以及模板编辑是否已直接影响源 `docx` 文件。
- **执行过程**:
1. 复核任务清单与当前代码链路,区分“块级编辑器界面改造”与“模板源写回 / 生成导出主链路改造”两个层面。
2. 核对 `generation_runtime.py``export.py`,确认当前执行生成和导出仍然基于旧 `paragraphs + generation_logs + template.file_path` 工作。
3. 核对 `templates.py`,确认当前模板编辑保存主要写入 `paragraphs``template_blocks`,尚未把编辑后的块结构回写到模板源 `docx`
- **执行结果**: 已明确当前“在线编辑文档效果”只完成了块级编辑器的可见前端基础,未完成模板源 `docx` 写回;执行生成和导出仍走旧段落链路,因此不会完全按新块顺序导出。这也是用户感知为“编辑后没有真正影响模板导出”的根本原因。
## 会话 ID: local-20260705211340
- [2026-07-05 21:13:40]
- **执行原因**: 用户要求继续实施,优先打通“保存模板影响源 docx”和“执行生成/导出顺序跟块走”的主链路。
- **执行过程**:
1.`templates.py` 中新增 `blocks -> paragraphs` 同步逻辑,使块顺序、块内容、AI/人工属性会反向更新旧 `Paragraph` 数据。
2. 在同一文件中新增模板快照写回逻辑:读取当前模板源 `docx`,按当前块顺序组装导出日志,通过 `export_document_bytes` 生成新的模板内容并覆盖回模板源文件。
3. 保留现有 `generation_runtime.py``export.py` 的旧段落链路不变,但通过同步段落顺序与内容,让执行生成和导出开始间接受到块顺序影响。
4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。
- **执行结果**: 当前保存模板后,后端已开始将 `template_blocks` 反向同步回 `paragraphs`,并尝试把当前块快照回写到模板源 `docx`;这为后续完全切换到 blocks 导出奠定了主链路基础,也开始让保存后的顺序和内容影响执行生成与导出。
## 会话 ID: local-20260705212155
- [2026-07-05 21:21:55]
- **执行原因**: 用户反馈“调整段落顺序后,点击保存会恢复之前的段落”。
- **执行过程**:
1. 复核前后端保存链路,确认问题出在“段落模式调整了 `paragraphs`,但保存时仍把旧 `blocks` 一并提交,后端又按旧 `blocks` 覆盖回段落顺序”。
2. 为模板保存请求新增 `save_mode` 字段,明确区分 `paragraph``manual` 两种保存语义。
3. 调整后端保存逻辑:只有在 `manual` 模式下才按 `blocks` 覆盖段落;在 `paragraph` 模式下则以 `paragraphs` 为准重建块数据,避免旧块顺序反向覆盖。
4. 调整前端 `TemplateEditor.vue``template` store,在自动保存与保存模板时按当前编辑模式传递 `save_mode`,并在段落模式下不再提交旧块数组。
5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复通过编译检查。
- **执行结果**: 已修复段落模式下“调整顺序后保存又恢复原顺序”的直接覆盖问题;当前段落模式保存会以 `paragraphs` 为准,不再被旧 `blocks` 顺序反向改写。
## 会话 ID: local-20260705213040
- [2026-07-05 21:30:40]
- **执行原因**: 用户反馈“执行生成里还是旧顺序,并且每一段都会重复导出”。
- **执行过程**:
1. 通过容器内 MySQL 查询模板 5 的 `paragraphs``template_blocks` 与最近生成记录,确认数据库中的最新顺序实际上已同步为用户调整后的顺序。
2. 定位重复导出的根因:当前模板下每个块都是单独段落,但历史保存逻辑将所有非标题段统一标为 `append_after_heading`,导出时会保留旧正文再追加一次新正文,导致每段重复。
3.`templates.py` 中新增块级写入方式解析逻辑:同一锚点下首个内容块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`;标题块仍使用 `replace_heading_only`
4. 立刻用当前模板详情数据回调一次 `PUT /templates/5/paragraphs`,触发模板 5 重新保存,使新的写入方式同步落库并回写模板快照。
5. 再次查询数据库确认模板 5 的 `paragraphs.write_mode` 已全部从错误的 `append_after_heading` 切换为 `replace_section`
- **执行结果**: 已修复模板 5 当前“每一段重复导出”的直接根因;数据库和最新生成链路所使用的模板顺序现已与用户调整后的顺序一致。后续需要重新发起新的生成任务,旧的历史生成记录不会自动变成新顺序与新导出结果。
## 会话 ID: local-20260705213910
- [2026-07-05 21:39:10]
- **执行原因**: 用户要求继续完善,进一步降低导出链路对旧 `paragraphs` 顺序的依赖,减少排序与重复类 bug。
- **执行过程**:
1. 改造 `export.py`,让 DOCX 导出优先按 `template_blocks` 顺序组织导出日志,而不是完全依赖 `GenerationLog + Paragraph` 的旧顺序。
2. 在导出组装阶段加入块级写入方式解析逻辑:同锚点首块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`,标题块使用 `replace_heading_only`
3.`document_export.py` 中新增章节重排逻辑,根据导出日志中的锚点顺序,先调整 Word 文档中各个 Heading section 的物理顺序,再执行正文替换与插入。
4. 保留无块数据时的旧段落导出回退逻辑,避免历史模板直接失效。
5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。
- **执行结果**: 当前 DOCX 导出已开始优先按 `template_blocks` 顺序工作,并且在写回前会尝试重排 Word 标题区块顺序;这比之前仅替换原位置内容更接近“模板编辑后导出顺序真实变化”的目标。
## 会话 ID: local-20260705214830
- [2026-07-05 21:48:30]
- **执行原因**: 用户要求将当前这批模板在线编辑与导出链路改造提交到 `test_v1` 分支。
- **执行过程**:
1. 检查当前工作区改动与现有分支,确认本轮需提交的是模板块模型、编辑器、导出链路、任务清单和任务记录相关文件。
2. 新建并切换到 `test_v1` 分支,排除不属于本轮的 `docs/tasks/task_detail_2026_07_03.md`、原型目录和额外文档改动。
3. 仅暂存本轮功能相关文件,并使用中文提交信息完成提交。
- **执行结果**: 已在 `test_v1` 分支完成本轮提交,提交号为 `c84ec6a`,提交信息为“模板在线编辑与导出链路重构”。
@@ -0,0 +1,138 @@
# AI 文档模板生成系统 · 模板在线编辑重构任务拆解清单
总工期估算:4-6 周(两人并行:前端 + 后端)
## 第一阶段:方案定稿与数据结构升级(第 1 周)
### 产品方案与交互定稿(2-3 天)
- [ ] 明确“在线编辑模式 / AI 选区模式”双模式交互
- [ ] 明确块类型定义(标题块 / 正文块 / 表格块 / AI 块 / 变量块)
- [ ] 明确 AI 选区后的操作菜单(设为 AI / 设为固定内容 / 设为变量)
- [ ] 明确模板保存后的基线含义(保存即修改模板源内容)
- [ ] 明确导出时的填充规则(固定内容保留,AI 块填充,变量块替换)
- [ ] 输出交互原型说明文档
### 数据模型升级(2-3 天)
- [x] 新增模板块表 `template_blocks`
- [x] 定义块字段(block_type / sort_index / parent_id / anchor_ref / content_json / style_json / edit_mode
- [x] 为 AI 块补充字段(placeholder_key / model_id / prompt_text / need_file / output_format
- [x] 为变量块补充字段(variable_key / default_value
- [x] 保留旧 `paragraphs` 结构用于兼容过渡
- [x] 输出数据库增量 SQL
## 第二阶段:模板解析器重构(第 1-2 周)
### Word 块级解析(4-5 天)
- [x] 解析 `.docx` 为块流结构,而不只按 Heading 识别段落
- [x] 识别标题块
- [x] 识别普通正文块
- [x] 识别表格块
- [ ] 识别空白分隔块
- [ ] 为每个块记录原始位置索引与样式快照
- [x] 输出新的块级 JSON 结构
### 特殊区域识别(2-3 天)
- [ ] 识别封面区内容(标题 / 日期 / 负责人 / 单位等)
- [ ] 识别摘要区内容
- [ ] 识别未使用 Heading 的小节正文
- [x] 识别连续正文中的可拆分候选块
- [ ] 输出模板体检提示信息
## 第三阶段:模板在线编辑器重构(第 2-3 周)
### 中间编辑画布(4-5 天)
- [x] 将当前段落预览区改造为块级编辑画布
- [x] 支持标题块直接编辑
- [x] 支持正文块直接编辑
- [ ] 支持表格块展示与基础编辑
- [x] 支持块新增
- [x] 支持块删除
- [x] 支持块上移 / 下移
- [ ] 支持块拆分 / 合并
### 编辑模式与 AI 选区模式(3-4 天)
- [ ] 增加“在线编辑模式 / AI 选区模式”切换
- [ ] 在线编辑模式下支持直接修改模板内容
- [ ] AI 选区模式下支持鼠标选中一段内容
- [ ] 选中内容后可设为 AI 块
- [ ] 选中内容后可设为变量块
- [ ] 支持在当前位置后插入新的 AI 块
- [ ] 支持取消 AI 标记并恢复为固定内容
### 左右侧配置区联动(2-3 天)
- [x] 左侧块列表与中间编辑画布联动高亮
- [x] 右侧根据当前选中块显示不同配置项
- [x] AI 块显示模型 / 提示词 / 文件要求 / 输出格式配置
- [x] 变量块显示变量名 / 默认值配置
- [x] 固定块显示只读或基础编辑信息
## 第四阶段:模板保存与模板源同步写回(第 3 周)
### 模板保存链路重构(3-4 天)
- [ ] 保存模板时持久化块结构而非仅保存段落配置
- [ ] 保存模板时同步生成最新模板快照
- [ ] 将最新模板快照写回模板源 `.docx`
- [ ] 保存后重新加载模板时展示最新编辑结果
- [ ] 补充模板版本或快照记录
### 模板源写回引擎(3-4 天)
- [ ] 支持标题块写回
- [ ] 支持正文块写回
- [ ] 支持删除块后同步从模板源移除
- [ ] 支持移动块后同步更新模板顺序
- [ ] 支持新增块后同步插入模板源
- [ ] 处理第一页封面区块写回
## 第五阶段:生成链路适配(第 3-4 周)
### AI 生成任务改造(3-4 天)
- [ ] 生成任务由“按段落”改为“按 AI 块”
- [ ] 固定块不参与 AI 生成
- [ ] 变量块按规则填充值
- [ ] AI 块支持单块测试生成
- [ ] AI 块支持多块批量生成
- [ ] AI 块支持失败回退与重试
### 文件与提示词链路适配(2-3 天)
- [ ] 文件上传改为绑定 AI 块
- [ ] 历史文件复用继续兼容
- [ ] 提示词配置迁移到 AI 块级别
- [ ] 输出格式配置迁移到 AI 块级别
## 第六阶段:导出引擎重构(第 4-5 周)
### Word 导出写回(4-5 天)
- [ ] 导出基于“最新模板快照”而不是原始导入模板
- [ ] 固定块保持模板编辑后的最终内容
- [ ] AI 块按占位位置写回生成结果
- [ ] 变量块按变量值替换
- [ ] 支持同一位置下多块顺序写回
- [ ] 支持文字与表格混排导出
- [ ] 保留主要样式与段落结构
### 导出正确性校验(2-3 天)
- [ ] 校验删除块后导出无残留
- [ ] 校验调整顺序后导出顺序正确
- [ ] 校验第一页内容导出正确
- [ ] 校验摘要区内容导出正确
- [ ] 校验多级标题结构导出正确
## 第七阶段:联调、修边与验收(第 5-6 周)
- [ ] 用真实复杂模板联调(含封面 / 摘要 / 多级标题 / 表格)
- [ ] 验证第一页块可删除、可调整、可导出
- [ ] 验证保存模板后再次进入能看到最新模板内容
- [ ] 验证 AI 选区转块后的生成与导出闭环
- [ ] 验证历史生成记录兼容旧数据
- [ ] 完善错误提示、加载状态与操作反馈
- [ ] 补充模板编辑使用说明
## 阶段交付物
| 阶段 | 交付物 |
|------|--------|
| 第 1 周 | 在线编辑方案、块模型设计、数据库增量方案 |
| 第 2 周 | 新版模板解析器、块级 JSON 结构、模板体检提示 |
| 第 3 周 | 块级在线编辑器、AI 选区模式、模板保存链路 |
| 第 4-5 周 | AI 块生成链路、导出写回引擎、真实模板导出闭环 |
| 第 6 周 | 全流程联调通过、使用说明与验收结果 |
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS template_blocks (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
source_paragraph_id INT DEFAULT NULL COMMENT '来源段落 ID',
parent_block_id INT DEFAULT NULL COMMENT '父块 ID',
sort_index INT DEFAULT 0 COMMENT '排序',
block_type VARCHAR(30) DEFAULT 'text' COMMENT 'heading/text/table/ai_slot/variable',
anchor_ref VARCHAR(500) DEFAULT '' COMMENT '原始锚点引用',
title VARCHAR(500) DEFAULT '' COMMENT '块标题',
content_json TEXT DEFAULT '{}' COMMENT '块内容 JSON',
style_json TEXT DEFAULT '{}' COMMENT '块样式 JSON',
edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai',
placeholder_key VARCHAR(120) DEFAULT '' COMMENT 'AI 占位键',
variable_key VARCHAR(120) DEFAULT '' COMMENT '变量键',
default_value TEXT DEFAULT '' COMMENT '默认值',
model_id INT DEFAULT NULL COMMENT '指定模型',
need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词',
prompt_text TEXT DEFAULT '' COMMENT '预设提示词',
need_file TINYINT(1) DEFAULT 0 COMMENT '是否需要参考文件',
file_note TEXT DEFAULT '' COMMENT '备注说明',
output_format VARCHAR(20) DEFAULT 'text' COMMENT 'text/table/mixed/chart',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_template_blocks_template FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
CONSTRAINT fk_template_blocks_paragraph FOREIGN KEY (source_paragraph_id) REFERENCES paragraphs(id) ON DELETE SET NULL,
CONSTRAINT fk_template_blocks_parent FOREIGN KEY (parent_block_id) REFERENCES template_blocks(id) ON DELETE SET NULL,
CONSTRAINT fk_template_blocks_model FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+32 -1
View File
@@ -30,12 +30,14 @@ CREATE TABLE IF NOT EXISTS paragraphs (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL, template_id INT NOT NULL,
sort_index INT DEFAULT 0 COMMENT '排序', sort_index INT DEFAULT 0 COMMENT '排序',
anchor_title VARCHAR(500) DEFAULT '' COMMENT '原始标题锚点',
title VARCHAR(500) DEFAULT '' COMMENT '段落标题', title VARCHAR(500) DEFAULT '' COMMENT '段落标题',
content TEXT DEFAULT '' COMMENT '正文内容', content TEXT DEFAULT '' COMMENT '正文内容',
style_json TEXT DEFAULT '{}' COMMENT '样式 JSON', style_json TEXT DEFAULT '{}' COMMENT '样式 JSON',
is_table TINYINT(1) DEFAULT 0 COMMENT '是否为表格', is_table TINYINT(1) DEFAULT 0 COMMENT '是否为表格',
table_json TEXT DEFAULT '{}' COMMENT '表格结构 JSON', table_json TEXT DEFAULT '{}' COMMENT '表格结构 JSON',
edit_mode VARCHAR(20) DEFAULT 'ai' COMMENT 'manual/ai', edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai',
write_mode VARCHAR(30) DEFAULT 'replace_section' COMMENT 'replace_section/append_after_heading/replace_heading_only',
model_id INT DEFAULT NULL COMMENT '指定模型', model_id INT DEFAULT NULL COMMENT '指定模型',
need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词', need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词',
prompt_text TEXT DEFAULT '' COMMENT '预设提示词', prompt_text TEXT DEFAULT '' COMMENT '预设提示词',
@@ -48,6 +50,35 @@ CREATE TABLE IF NOT EXISTS paragraphs (
FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS template_blocks (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
source_paragraph_id INT DEFAULT NULL COMMENT '来源段落 ID',
parent_block_id INT DEFAULT NULL COMMENT '父块 ID',
sort_index INT DEFAULT 0 COMMENT '排序',
block_type VARCHAR(30) DEFAULT 'text' COMMENT 'heading/text/table/ai_slot/variable',
anchor_ref VARCHAR(500) DEFAULT '' COMMENT '原始锚点引用',
title VARCHAR(500) DEFAULT '' COMMENT '块标题',
content_json TEXT DEFAULT '{}' COMMENT '块内容 JSON',
style_json TEXT DEFAULT '{}' COMMENT '块样式 JSON',
edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai',
placeholder_key VARCHAR(120) DEFAULT '' COMMENT 'AI 占位键',
variable_key VARCHAR(120) DEFAULT '' COMMENT '变量键',
default_value TEXT DEFAULT '' COMMENT '默认值',
model_id INT DEFAULT NULL COMMENT '指定模型',
need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词',
prompt_text TEXT DEFAULT '' COMMENT '预设提示词',
need_file TINYINT(1) DEFAULT 0 COMMENT '是否需要参考文件',
file_note TEXT DEFAULT '' COMMENT '备注说明',
output_format VARCHAR(20) DEFAULT 'text' COMMENT 'text/table/mixed/chart',
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,
FOREIGN KEY (source_paragraph_id) REFERENCES paragraphs(id) ON DELETE SET NULL,
FOREIGN KEY (parent_block_id) REFERENCES template_blocks(id) ON DELETE SET NULL,
FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS documents ( CREATE TABLE IF NOT EXISTS documents (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL, template_id INT NOT NULL,
+1
View File
@@ -6,4 +6,5 @@ export const modelApi = {
update: (id: number, data: any) => http.put(`/models/${id}`, data), update: (id: number, data: any) => http.put(`/models/${id}`, data),
delete: (id: number) => http.delete(`/models/${id}`), delete: (id: number) => http.delete(`/models/${id}`),
test: (id: number) => http.post(`/models/${id}/test`), test: (id: number) => http.post(`/models/${id}/test`),
balance: (id: number) => http.get(`/models/${id}/balance`),
} }
@@ -0,0 +1,303 @@
<template>
<div :class="['reference-selector', variant]">
<div v-if="variant === 'full'" class="upload-card">
<div v-if="title" class="upload-title">{{ title }}</div>
<div v-if="description" class="upload-desc">{{ description }}</div>
<a-upload-dragger :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
</a-upload-dragger>
</div>
<div v-else class="compact-toolbar">
<a-upload :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<a-button size="small" :loading="uploading">{{ hasSelection ? '继续上传' : '上传文件' }}</a-button>
</a-upload>
<a-button size="small" @click="toggleHistoryPanel">{{ historyPanelOpen ? '收起历史文件' : '选择历史文件' }}</a-button>
</div>
<div v-if="selectedFiles.length" class="selected-list">
<div class="selected-item" v-for="item in selectedFiles" :key="item.file_path">
<span class="selected-name">{{ item.file_name }}</span>
<a-button type="link" size="small" danger @click="removeSelectedFile(item.file_path)">
{{ variant === 'full' ? '移除' : 'x' }}
</a-button>
</div>
</div>
<div v-if="showHistorySection" class="history-card">
<div class="history-head">
<div>
<div class="history-title">历史文件</div>
<div class="history-desc">已上传过的文件会保存在系统里下次可直接选择复用</div>
</div>
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
</div>
<div class="history-search">
<a-input-search
v-model:value="historyKeyword"
placeholder="按文件名搜索历史文件"
allow-clear
@search="fetchReferenceHistory"
/>
</div>
<a-spin :spinning="historyLoading">
<div v-if="referenceHistory.length" class="history-list">
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
<input
type="checkbox"
:checked="isSelected(item.file_path)"
@change="toggleHistoryFile(item)"
/>
<div class="history-item-main">
<div class="history-item-name">{{ item.file_name }}</div>
<div class="history-item-meta">
<span>{{ formatFileSize(item.file_size) }}</span>
<span>{{ formatDateTime(item.created_at) }}</span>
</div>
</div>
</label>
</div>
<a-empty v-else description="暂无历史文件" />
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined } from '@ant-design/icons-vue'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
type SelectedFile = { file_name: string; file_path: string }
const props = withDefaults(defineProps<{
modelValue: SelectedFile[]
title?: string
description?: string
variant?: 'full' | 'compact'
}>(), {
title: '',
description: '',
variant: 'full',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: SelectedFile[]): void
}>()
const uploading = ref(false)
const referenceHistory = ref<ReferenceFile[]>([])
const historyKeyword = ref('')
const historyLoading = ref(false)
const historyPanelOpen = ref(false)
const selectedFiles = computed(() => props.modelValue || [])
const hasSelection = computed(() => selectedFiles.value.length > 0)
const showHistorySection = computed(() => props.variant === 'full' || historyPanelOpen.value)
function updateFiles(files: SelectedFile[]) {
emit('update:modelValue', files)
}
function appendSelectedFile(file: SelectedFile) {
if (selectedFiles.value.some((item) => item.file_path === file.file_path)) return
updateFiles([...selectedFiles.value, file])
}
function removeSelectedFile(filePath: string) {
updateFiles(selectedFiles.value.filter((item) => item.file_path !== filePath))
}
function isSelected(filePath: string) {
return selectedFiles.value.some((item) => item.file_path === filePath)
}
function toggleHistoryFile(item: ReferenceFile) {
if (isSelected(item.file_path)) {
removeSelectedFile(item.file_path)
return
}
appendSelectedFile({ file_name: item.file_name, file_path: item.file_path })
}
async function beforeUpload(file: File) {
try {
uploading.value = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
appendSelectedFile({ file_name: res.data.file_name, file_path: res.data.file_path })
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploading.value = false
}
return false
}
async function fetchReferenceHistory() {
historyLoading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: 1,
page_size: 30,
keyword: historyKeyword.value.trim(),
})
referenceHistory.value = response.data?.items || []
} catch (error: any) {
message.error(error.message || '加载历史文件失败')
} finally {
historyLoading.value = false
}
}
function toggleHistoryPanel() {
historyPanelOpen.value = !historyPanelOpen.value
if (historyPanelOpen.value && !referenceHistory.value.length) {
fetchReferenceHistory()
}
}
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) : ''
}
onMounted(() => {
if (props.variant === 'full') {
fetchReferenceHistory()
}
})
</script>
<style scoped>
.reference-selector {
display: grid;
gap: 16px;
}
.upload-card {
background: #f0f1f3;
border-radius: 8px;
padding: 16px;
}
.upload-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.upload-desc {
font-size: 12px;
color: #5b626e;
margin-bottom: 12px;
}
.compact-toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
}
.history-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.history-desc {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.history-search {
margin: 12px 0;
}
.history-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow-y: auto;
}
.history-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
cursor: pointer;
}
.history-item-main {
min-width: 0;
flex: 1;
}
.history-item-name {
font-size: 13px;
font-weight: 500;
color: #111827;
word-break: break-all;
}
.history-item-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.selected-list {
display: grid;
gap: 8px;
}
.selected-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: #f5f6f8;
border-radius: 8px;
padding: 8px 12px;
}
.selected-name {
min-width: 0;
font-size: 13px;
word-break: break-all;
}
</style>
+2 -1
View File
@@ -12,6 +12,7 @@ export const useModelStore = defineStore('model', () => {
async function update(id: number, data: any) { await modelApi.update(id, data); await fetchList() } async function update(id: number, data: any) { await modelApi.update(id, data); await fetchList() }
async function remove(id: number) { await modelApi.delete(id); await fetchList() } async function remove(id: number) { await modelApi.delete(id); await fetchList() }
async function test(id: number) { return await modelApi.test(id) } async function test(id: number) { return await modelApi.test(id) }
async function balance(id: number) { return await modelApi.balance(id) }
return { models, loading, fetchList, create, update, remove, test } return { models, loading, fetchList, create, update, remove, test, balance }
}) })
+31 -5
View File
@@ -1,19 +1,45 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { templateApi } from '@/api/template' import { templateApi } from '@/api/template'
import type { Template, Paragraph } from '@/types' import type { Template, Paragraph, TemplateBlock } from '@/types'
export const useTemplateStore = defineStore('template', () => { export const useTemplateStore = defineStore('template', () => {
const templates = ref<Template[]>([]) const templates = ref<Template[]>([])
const currentTemplate = ref<Template | null>(null) const currentTemplate = ref<Template | null>(null)
const paragraphs = ref<Paragraph[]>([]) const paragraphs = ref<Paragraph[]>([])
const blocks = ref<TemplateBlock[]>([])
const loading = ref(false) const loading = ref(false)
async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } } async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } }
async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data } async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; blocks.value = r.data?.blocks || []; return r.data }
async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data } async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; blocks.value = r.data?.blocks || []; return r.data }
async function save(id: number) { const data = paragraphs.value.map(p => ({ id: p.id, sort_index: p.sort_index, title: p.title, edit_mode: p.edit_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) } async function save(id: number) {
const data = paragraphs.value.map((p, index) => ({ id: p.id, sort_index: index + 1, anchor_title: p.anchor_title, title: p.title, content: p.content, edit_mode: p.edit_mode, write_mode: p.write_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format }))
const blockData = blocks.value.map((block, index) => ({
id: block.id,
source_paragraph_id: block.source_paragraph_id,
parent_block_id: block.parent_block_id,
sort_index: index + 1,
block_type: block.block_type,
anchor_ref: block.anchor_ref,
title: block.title,
content_json: block.content_json,
style_json: block.style_json,
edit_mode: block.edit_mode,
placeholder_key: block.placeholder_key,
variable_key: block.variable_key,
default_value: block.default_value,
model_id: block.model_id,
need_prompt: block.need_prompt,
prompt_text: block.prompt_text,
need_file: block.need_file,
file_note: block.file_note,
output_format: block.output_format,
}))
const r: any = await templateApi.saveParagraphs(id, { save_mode: 'manual', paragraphs: data, blocks: blockData })
blocks.value = r.data?.blocks || blocks.value
}
async function remove(id: number) { await templateApi.delete(id); await fetchList() } async function remove(id: number) { await templateApi.delete(id); await fetchList() }
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove } return { templates, currentTemplate, paragraphs, blocks, loading, fetchList, fetchOne, upload, save, remove }
}) })
+26
View File
@@ -1,16 +1,42 @@
export interface Template { export interface Template {
id: number; name: string; description: string; file_path: string id: number; name: string; description: string; file_path: string
paragraph_count: number; status: string; created_at: string; updated_at: string paragraph_count: number; status: string; created_at: string; updated_at: string
blocks?: TemplateBlock[]
} }
export interface Paragraph { export interface Paragraph {
id: number; template_id: number; sort_index: number; title: string; content: string id: number; template_id: number; sort_index: number; title: string; content: string
anchor_title: string
style_json: string; is_table: boolean; table_json: string style_json: string; is_table: boolean; table_json: string
edit_mode: 'manual' | 'ai'; model_id: number | null edit_mode: 'manual' | 'ai'; model_id: number | null
write_mode: 'replace_section' | 'append_after_heading' | 'replace_heading_only'
need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string
output_format: 'text' | 'table' | 'mixed' | 'chart' output_format: 'text' | 'table' | 'mixed' | 'chart'
} }
export interface TemplateBlock {
id: number
template_id?: number
source_paragraph_id?: number | null
parent_block_id?: number | null
sort_index: number
block_type: 'heading' | 'text' | 'table' | 'ai_slot' | 'variable'
anchor_ref: string
title: string
content_json: Record<string, any>
style_json: string
edit_mode: 'manual' | 'ai'
placeholder_key: string
variable_key: string
default_value: string
model_id: number | null
need_prompt: boolean
prompt_text: string
need_file: boolean
file_note: string
output_format: 'text' | 'table' | 'mixed' | 'chart'
}
export interface AiModel { export interface AiModel {
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai' id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled' api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled'
+19 -36
View File
@@ -64,15 +64,10 @@
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag> <a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div> </div>
<div v-if="paragraph.need_file" class="file-info"> <div v-if="paragraph.need_file" class="file-info">
<a-upload :multiple="true" :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false"> <ReferenceFileSelector
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }}</a-button> v-model="uploadedFiles[paragraph.id]"
</a-upload> variant="compact"
<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> </div>
<span v-else class="no-file-tag">无需上传</span> <span v-else class="no-file-tag">无需上传</span>
</div> </div>
@@ -89,12 +84,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template' import { useTemplateStore } from '@/stores/template'
import { useDocumentStore } from '@/stores/document' import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate' import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -105,8 +100,6 @@ const templates = ref<any[]>([])
const paragraphs = ref<any[]>([]) const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined) const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: 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 generating = ref(false)
const tplInfo = ref<any>({}) const tplInfo = ref<any>({})
@@ -115,6 +108,19 @@ const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mod
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).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 || '') const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
watch(
uploadedFiles,
(value) => {
const filePaths = Object.fromEntries(
Object.entries(value).map(([key, items]) => [Number(key), (items || []).map((item) => item.file_path)])
)
uploadedFilePaths.value = filePaths
},
{ deep: true }
)
const uploadedFilePaths = ref<Record<number, string[]>>({})
async function refreshTemplates() { async function refreshTemplates() {
await tplStore.fetchList() await tplStore.fetchList()
templates.value = tplStore.templates as any templates.value = tplStore.templates as any
@@ -140,29 +146,6 @@ async function onTplChange(id: number) {
uploadedFilePaths.value = {} uploadedFilePaths.value = {}
} }
async function handleFileUpload(paragraphId: number, file: File) {
try {
uploadingMap.value[paragraphId] = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
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 || '文件上传失败')
} finally {
uploadingMap.value[paragraphId] = false
}
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() { async function startGen() {
if (!selectedTplId.value) { if (!selectedTplId.value) {
message.warning('请先选择模板') message.warning('请先选择模板')
+75 -9
View File
@@ -20,12 +20,21 @@
<div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div> <div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
<div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div> <div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div>
<div class="mc-provider">思考模式{{ item.enable_reasoning ? '开启' : '关闭' }}</div> <div class="mc-provider">思考模式{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
<div v-if="isDeepSeek(item) && balanceMap[item.id]" class="mc-provider">
余额{{ formatBalanceText(balanceMap[item.id]) }}
</div>
</div> </div>
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']"> <div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }} {{ item.status === 'enabled' ? '已启用' : '已禁用' }}
</div> </div>
<div class="mc-actions"> <div class="mc-actions">
<a-button size="small" @click="runTest(item)">测试</a-button> <a-button size="small" :loading="testingMap[item.id]" @click="runTest(item)">
<template #icon><reload-outlined /></template>
刷新测试
</a-button>
<a-button v-if="isDeepSeek(item)" size="small" :loading="balanceLoadingMap[item.id]" @click="fetchBalance(item)">
查看余额
</a-button>
<a-button size="small" @click="openEdit(item)">编辑</a-button> <a-button size="small" @click="openEdit(item)">编辑</a-button>
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button> <a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
</div> </div>
@@ -39,7 +48,13 @@
<a-input v-model:value="form.name" /> <a-input v-model:value="form.name" />
</a-form-item> </a-form-item>
<a-form-item label="供应厂商"> <a-form-item label="供应厂商">
<a-input v-model:value="form.provider" /> <a-select v-model:value="providerPreset" @change="applyProviderPreset">
<a-select-option value="deepseek">DeepSeek</a-select-option>
<a-select-option value="custom">自定义</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="providerPreset === 'custom'" label="自定义厂商名称">
<a-input v-model:value="form.provider" placeholder="例如 OpenAI / Anthropic / 其他" />
</a-form-item> </a-form-item>
<a-form-item label="API 格式"> <a-form-item label="API 格式">
<a-select v-model:value="form.api_format"> <a-select v-model:value="form.api_format">
@@ -66,7 +81,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { Modal, message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { useModelStore } from '@/stores/model' import { useModelStore } from '@/stores/model'
const store = useModelStore() const store = useModelStore()
@@ -75,6 +91,41 @@ const modalOpen = ref(false)
const isEdit = ref(false) const isEdit = ref(false)
const editId = ref(0) const editId = ref(0)
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }) const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false })
const providerPreset = ref<'deepseek' | 'custom'>('custom')
const testingMap = ref<Record<number, boolean>>({})
const balanceLoadingMap = ref<Record<number, boolean>>({})
const balanceMap = ref<Record<number, { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }>>({})
function isDeepSeek(item: any) {
const provider = (item?.provider || '').trim().toLowerCase()
return provider === 'deepseek'
}
function applyProviderPreset(value: 'deepseek' | 'custom') {
if (value === 'deepseek') {
form.value.provider = 'DeepSeek'
form.value.api_format = 'openai'
if (!form.value.api_endpoint || form.value.api_endpoint.includes('deepseek')) {
form.value.api_endpoint = 'https://api.deepseek.com'
}
return
}
if (form.value.provider === 'DeepSeek') {
form.value.provider = ''
}
}
function inferProviderPreset(item?: any) {
providerPreset.value = isDeepSeek(item || form.value) ? 'deepseek' : 'custom'
}
function formatBalanceText(data: { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }) {
const infos = data?.balance_infos || []
if (!infos.length) return data?.is_available ? '可用' : '不可用'
return infos
.map((item) => `${item.currency} ${item.total_balance}(充值 ${item.topped_up_balance} / 赠送 ${item.granted_balance}`)
.join('')
}
async function refreshList() { async function refreshList() {
await store.fetchList() await store.fetchList()
@@ -88,6 +139,8 @@ onMounted(async () => {
function openAdd() { function openAdd() {
isEdit.value = false isEdit.value = false
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false } form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
providerPreset.value = 'deepseek'
applyProviderPreset('deepseek')
modalOpen.value = true modalOpen.value = true
} }
@@ -103,6 +156,7 @@ function openEdit(item: any) {
supports_streaming: !!item.supports_streaming, supports_streaming: !!item.supports_streaming,
enable_reasoning: !!item.enable_reasoning, enable_reasoning: !!item.enable_reasoning,
} }
inferProviderPreset(item)
modalOpen.value = true modalOpen.value = true
} }
@@ -124,15 +178,27 @@ async function toggleStatus(item: any) {
} }
async function runTest(item: any) { async function runTest(item: any) {
testingMap.value[item.id] = true
try { try {
const result: any = await store.test(item.id) const result: any = await store.test(item.id)
Modal.info({ message.success(result.data?.message || `模型 ${item.name} 测试成功`, 2)
title: '连接测试结果',
width: 680,
content: JSON.stringify(result.data, null, 2),
})
} catch (error: any) { } catch (error: any) {
message.error(error.message || '连接测试失败') message.error(error.message || '连接测试失败', 2)
} finally {
testingMap.value[item.id] = false
}
}
async function fetchBalance(item: any) {
balanceLoadingMap.value[item.id] = true
try {
const result: any = await store.balance(item.id)
balanceMap.value[item.id] = result.data
message.success(`已刷新 ${item.name} 余额`, 2)
} catch (error: any) {
message.error(error.message || '余额查询失败', 2)
} finally {
balanceLoadingMap.value[item.id] = false
} }
} }
</script> </script>
+130 -16
View File
@@ -33,9 +33,16 @@
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div> <div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
</a-card> </a-card>
<a-card class="mapping-card" title="段落与附件映射"> <div class="detail-layout">
<aside class="detail-left">
<a-card class="left-card" title="段落列表">
<div v-if="paragraphMappings.length" class="mapping-list"> <div v-if="paragraphMappings.length" class="mapping-list">
<div v-for="item in paragraphMappings" :key="item.paragraph_id" class="mapping-item"> <div
v-for="item in paragraphMappings"
:key="item.paragraph_id"
:class="['mapping-item', { active: selectedParagraphId === item.paragraph_id }]"
@click="selectParagraph(item.paragraph_id)"
>
<div class="mapping-top"> <div class="mapping-top">
<span class="mapping-index">{{ item.sort_index }}</span> <span class="mapping-index">{{ item.sort_index }}</span>
<div class="mapping-main"> <div class="mapping-main">
@@ -43,27 +50,38 @@
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div> <div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
</div> </div>
</div> </div>
<div class="mapping-status">
<a-badge :status="statusBadge(logStatusMap[item.paragraph_id] || 'pending')" :text="statusText(logStatusMap[item.paragraph_id] || 'pending')" />
</div>
<div v-if="item.selected_files?.length" class="mapping-files"> <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> <span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div> </div>
<div v-else class="mapping-empty">未选择附件</div> <div v-else class="mapping-empty">未选择附件</div>
</div> </div>
</div> </div>
<a-empty v-else description="当前任务未记录附件映射" /> <a-empty v-else description="当前任务未记录段落信息" />
</a-card> </a-card>
</aside>
<section class="detail-right">
<a-card class="preview-card" title="生成结果预览"> <a-card class="preview-card" title="生成结果预览">
<div v-if="logs.length" class="preview-wrap"> <template v-if="selectedParagraph">
<section v-for="item in logs" :key="item.paragraph_id" :id="`section-${item.paragraph_id}`" class="preview-section"> <div class="preview-section">
<div class="preview-section-head"> <div class="preview-section-head">
<h3>{{ item.title }}</h3> <h3>{{ selectedParagraph.title }}</h3>
<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" /> <a-badge :status="statusBadge(selectedParagraph.status)" :text="statusText(selectedParagraph.status)" />
</div> </div>
<div class="preview-block" v-html="renderBlocks(item.content?.content || [])" /> <div v-if="selectedMapping?.file_note" class="preview-note">文件要求{{ selectedMapping.file_note }}</div>
<div v-if="selectedMapping?.selected_files?.length" class="preview-files">
<span v-for="file in selectedMapping.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div>
<div class="preview-block" v-html="renderBlocks(selectedParagraph.content?.content || [])" />
</div>
</template>
<a-empty v-else :description="isRunning ? '任务进行中,当前段落结果尚未生成' : '当前没有可预览的段落结果'" />
</a-card>
</section> </section>
</div> </div>
<a-empty v-else :description="isRunning ? '任务进行中,已完成段落会逐步出现在这里' : '暂无生成内容'" />
</a-card>
</div> </div>
</template> </template>
@@ -96,11 +114,21 @@ const documentInfo = ref<any>({})
const logs = ref<LogItem[]>([]) const logs = ref<LogItem[]>([])
const progressPercent = ref(0) const progressPercent = ref(0)
const progressMessage = ref('') const progressMessage = ref('')
const selectedParagraphId = ref<number | null>(null)
let progressSource: EventSource | null = null let progressSource: EventSource | null = null
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status)) const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
const isCompleted = computed(() => documentInfo.value.status === 'completed') const isCompleted = computed(() => documentInfo.value.status === 'completed')
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || []) const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
const logStatusMap = computed(() =>
Object.fromEntries(logs.value.map((item) => [item.paragraph_id, item.status]))
)
const selectedParagraph = computed(() =>
logs.value.find((item) => item.paragraph_id === selectedParagraphId.value) || null
)
const selectedMapping = computed(() =>
paragraphMappings.value.find((item: any) => item.paragraph_id === selectedParagraphId.value) || null
)
function statusText(status: string) { function statusText(status: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -145,6 +173,20 @@ async function loadDocument() {
const response: any = await generateApi.getDocument(id) const response: any = await generateApi.getDocument(id)
documentInfo.value = response.data || {} documentInfo.value = response.data || {}
logs.value = response.data?.logs || [] logs.value = response.data?.logs || []
if (!selectedParagraphId.value) {
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
}
if (
selectedParagraphId.value &&
!paragraphMappings.value.some((item: any) => item.paragraph_id === selectedParagraphId.value) &&
!logs.value.some((item) => item.paragraph_id === selectedParagraphId.value)
) {
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
}
}
function selectParagraph(paragraphId: number) {
selectedParagraphId.value = paragraphId
} }
function bindProgress() { function bindProgress() {
@@ -200,6 +242,11 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.detail-page { .detail-page {
padding: 24px; padding: 24px;
height: calc(100vh - 52px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
} }
.detail-head { .detail-head {
@@ -254,10 +301,38 @@ onBeforeUnmount(() => {
} }
.status-card, .status-card,
.mapping-card,
.preview-card { .preview-card {
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 18px; border-radius: 18px;
flex-shrink: 0;
}
.detail-layout {
display: flex;
gap: 16px;
align-items: flex-start;
min-height: 0;
flex: 1;
overflow: hidden;
}
.detail-left {
width: 360px;
flex-shrink: 0;
height: 100%;
min-height: 0;
}
.detail-right {
min-width: 0;
flex: 1;
height: 100%;
min-height: 0;
}
.left-card {
border-radius: 18px;
height: 100%;
} }
.status-message { .status-message {
@@ -276,6 +351,18 @@ onBeforeUnmount(() => {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 14px; border-radius: 14px;
background: #fafbfc; background: #fafbfc;
cursor: pointer;
transition: all 0.15s ease;
}
.mapping-item:hover {
border-color: #c7d2fe;
background: #f8faff;
}
.mapping-item.active {
border-color: #4f46e5;
background: #eef2ff;
} }
.mapping-top { .mapping-top {
@@ -312,6 +399,10 @@ onBeforeUnmount(() => {
color: #6b7280; color: #6b7280;
} }
.mapping-status {
margin-top: 10px;
}
.mapping-files { .mapping-files {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -334,16 +425,12 @@ onBeforeUnmount(() => {
color: #9ca3af; color: #9ca3af;
} }
.preview-wrap {
display: grid;
gap: 16px;
}
.preview-section { .preview-section {
padding: 16px; padding: 16px;
border-radius: 14px; border-radius: 14px;
background: #fff; background: #fff;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
min-height: 100%;
} }
.preview-section-head { .preview-section-head {
@@ -358,6 +445,33 @@ onBeforeUnmount(() => {
margin: 0; margin: 0;
} }
.preview-note {
margin-bottom: 12px;
font-size: 13px;
color: #6b7280;
}
.preview-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
:deep(.left-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.preview-card) {
height: 100%;
}
:deep(.preview-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.result-text) { :deep(.result-text) {
margin: 0 0 12px; margin: 0 0 12px;
line-height: 1.8; line-height: 1.8;
File diff suppressed because it is too large Load Diff