This commit is contained in:
zwt13703 2026-07-05 23:39:59 +08:00
parent 6f019610f8
commit 4c9924a5e7
10 changed files with 166 additions and 1066 deletions

View File

@ -22,7 +22,6 @@ async def get_db():
async def init_db():
from models.template import Template
from models.paragraph import Paragraph
from models.template_block import TemplateBlock
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
@ -54,6 +53,3 @@ async def init_db():
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))

View File

@ -1,6 +1,5 @@
from models.template import Template
from models.paragraph import Paragraph
from models.template_block import TemplateBlock
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog

View File

@ -15,7 +15,6 @@ from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from models.template_block import TemplateBlock
from services.document_export import export_document_bytes
from services.minio_client import (
download_object_bytes,
@ -27,46 +26,6 @@ from services.minio_client import (
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")
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
@ -80,40 +39,13 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
template_bucket, template_object = split_bucket_path(template.file_path)
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(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
logs = []
for log, paragraph in result.all():
logs.append(
{

View File

@ -1,5 +1,4 @@
import asyncio
import json
import os
import tempfile
import uuid
@ -16,10 +15,8 @@ from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from models.template_block import TemplateBlock
from schemas.schemas import Response, TemplateSave
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.minio_client import minio_client
from services.template_parser import parse_template
router = APIRouter()
@ -67,270 +64,6 @@ 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("")
async def list_templates(
page: int = Query(1, ge=1),
@ -367,16 +100,9 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
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]
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
payload = _serialize_template(template)
payload["paragraphs"] = paragraphs
payload["blocks"] = blocks
return Response(data=payload)
@ -435,35 +161,19 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
style_json=item.style_json,
is_table=item.is_table,
table_json=item.table_json,
edit_mode=item.edit_mode,
edit_mode="manual",
write_mode=item.write_mode,
need_prompt=item.edit_mode == "ai",
output_format=item.output_format,
)
db.add(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.refresh(template)
for paragraph in paragraph_rows:
await db.refresh(paragraph)
for block in block_rows:
await db.refresh(block)
payload = _serialize_template(template)
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
payload["blocks"] = [_serialize_block(item) for item in block_rows]
return Response(data=payload)
@ -514,24 +224,10 @@ async def save_template_paragraphs(
paragraph.file_note = config.file_note
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()
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})
template.paragraph_count = len(body.paragraphs)
await db.commit()
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
@router.delete("/{template_id}")

View File

@ -43,32 +43,8 @@ class ParagraphConfig(BaseModel):
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
need_prompt: bool = True
prompt_text: str = ""
need_file: bool = False
file_note: str = ""
output_format: str = "text"
class TemplateSave(BaseModel):
save_mode: str = "paragraph"
paragraphs: list[ParagraphConfig] = []
blocks: list[TemplateBlockConfig] = []
# 模型
class AiModelCreate(BaseModel):

View File

@ -69,77 +69,6 @@ def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors:
_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):
element = paragraph._element
for child in list(element):
@ -440,8 +369,6 @@ def _replace_section_group(
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
ordered_anchors = _ordered_unique_anchors(logs)
_reorder_heading_sections(document, ordered_anchors)
referenced_anchors: set[str] = set()
for item in logs:

View File

@ -1,5 +1,4 @@
import json
import re
from collections.abc import Iterator
from dataclasses import dataclass
@ -23,12 +22,6 @@ class ParsedParagraph:
is_table: bool
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]:
@ -166,35 +159,11 @@ 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]:
document = Document(file_path)
parsed: list[ParsedParagraph] = []
current_heading: str | None = None
current_heading_style_json = "{}"
current_item: ParsedParagraph | None = None
loose_table_count = 0
body_block_count = 0
preface_count = 0
for block in _iter_block_items(document):
if isinstance(block, Paragraph):
@ -204,79 +173,52 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
level = _heading_level(block.style.name if block.style is not None else "")
if level is not None:
current_heading = text
current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False)
body_block_count = 0
parsed.append(ParsedParagraph(
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=text,
title=text,
content="",
style_json=current_heading_style_json,
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
is_table=False,
table_json="{}",
write_mode="replace_heading_only",
block_type="heading",
edit_mode="manual",
output_format="text",
))
write_mode="replace_section",
)
parsed.append(current_item)
continue
block_type, placeholder_key, variable_key = _classify_placeholder(text)
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(
if current_item is None:
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=anchor_title,
title=title,
anchor_title="未命名段落",
title="未命名段落",
content=text,
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
is_table=False,
table_json="{}",
write_mode=write_mode,
block_type=block_type,
placeholder_key=placeholder_key,
variable_key=variable_key,
default_value="" if variable_key else text,
edit_mode="ai" if block_type == "ai_slot" else "manual",
output_format="text",
))
write_mode="replace_section",
)
parsed.append(current_item)
else:
current_item.content = "\n".join(filter(None, [current_item.content, text]))
else:
table_data = _extract_table_data(block)
table_text = f"[表格] {table_data['rows']}{table_data['cols']}"
if current_heading is None:
if current_item is None:
loose_table_count += 1
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(
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=anchor_title,
title=title,
anchor_title=f"表格_{loose_table_count}",
title=f"表格_{loose_table_count}",
content=table_text,
style_json=current_heading_style_json if current_heading else "{}",
style_json="{}",
is_table=True,
table_json=json.dumps(table_data, ensure_ascii=False),
write_mode=write_mode,
block_type="table",
default_value=table_text,
edit_mode="manual",
output_format="table",
))
write_mode="replace_section",
)
parsed.append(current_item)
else:
current_item.is_table = True
current_item.table_json = json.dumps(table_data, ensure_ascii=False)
current_item.content = "\n".join(filter(None, [current_item.content, table_text]))
return parsed

View File

@ -1,45 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { templateApi } from '@/api/template'
import type { Template, Paragraph, TemplateBlock } from '@/types'
import type { Template, Paragraph } from '@/types'
export const useTemplateStore = defineStore('template', () => {
const templates = ref<Template[]>([])
const currentTemplate = ref<Template | null>(null)
const paragraphs = ref<Paragraph[]>([])
const blocks = ref<TemplateBlock[]>([])
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 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 || []; blocks.value = r.data?.blocks || []; return r.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 fetchOne(id: number) { const r: any = await templateApi.get(id); 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 || []; return r.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 })); await templateApi.saveParagraphs(id, { paragraphs: data }) }
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
return { templates, currentTemplate, paragraphs, blocks, loading, fetchList, fetchOne, upload, save, remove }
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
})

View File

@ -1,7 +1,6 @@
export interface Template {
id: number; name: string; description: string; file_path: string
paragraph_count: number; status: string; created_at: string; updated_at: string
blocks?: TemplateBlock[]
}
export interface Paragraph {
@ -14,29 +13,6 @@ export interface Paragraph {
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 {
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'

View File

@ -18,29 +18,29 @@
<div class="editor-layout">
<aside class="editor-left">
<div class="left-head">
{{ editorMode === 'paragraph' ? '段落列表' : '内容块列表' }}
段落列表
<span class="left-head-tip">(点击定位)</span>
</div>
<div class="left-scroll">
<div
v-for="item in currentItems"
:key="item.id"
:class="['para-list-item', { active: selectedId === item.id }]"
@click="selectPara(item.id)"
v-for="paragraph in paragraphs"
:key="paragraph.id"
:class="['para-list-item', { active: selectedId === paragraph.id }]"
@click="selectPara(paragraph.id)"
>
<span class="pli-index">{{ item.sort_index }}</span>
<span class="pli-title">{{ listTitle(item) }}</span>
<span :class="['pli-badge', editorMode === 'manual' ? `block-${item.block_type || 'text'}` : (item.edit_mode === 'ai' ? 'ai' : 'manual')]">
{{ editorMode === 'manual' ? blockTypeLabel(item.block_type) : (item.edit_mode === 'ai' ? 'AI 生成' : '人工编辑') }}
<span class="pli-index">{{ paragraph.sort_index }}</span>
<span class="pli-title">{{ listTitle(paragraph) }}</span>
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
</span>
<span class="pli-actions" @click.stop>
<a-button type="text" size="small" :disabled="!canMoveUp(item)" @click="moveUp(item)">
<a-button type="text" size="small" :disabled="!canMoveUp(paragraph)" @click="moveUp(paragraph)">
<arrow-up-outlined />
</a-button>
<a-button type="text" size="small" :disabled="!canMoveDown(item)" @click="moveDown(item)">
<a-button type="text" size="small" :disabled="!canMoveDown(paragraph)" @click="moveDown(paragraph)">
<arrow-down-outlined />
</a-button>
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(item)" @click="handleDeleteParagraph(item)">
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(paragraph)" @click="handleDeleteParagraph(paragraph)">
<delete-outlined />
</a-button>
</span>
@ -66,17 +66,10 @@
<span class="toolbar-hint">
{{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }}
</span>
<template v-if="editorMode === 'manual'">
<span class="tb-divider" />
<a-button size="small" @click="appendBlock('heading')">新增标题块</a-button>
<a-button size="small" @click="appendBlock('text')">新增正文块</a-button>
<a-button size="small" type="primary" ghost @click="appendBlock('ai_slot')">新增 AI </a-button>
<a-button size="small" @click="appendBlock('variable')">新增变量块</a-button>
</template>
</div>
<div class="center-scroll">
<div :class="['doc-edit-page', { 'doc-edit-page-manual': editorMode === 'manual' }]">
<div class="doc-edit-page">
<template v-if="editorMode === 'paragraph'">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
<span class="para-block-actions" @click.stop>
@ -100,52 +93,45 @@
</template>
<template v-else>
<div
v-for="block in blocks"
:key="block.id"
:class="['para-block', 'manual-block', blockCardClass(block), { selected: selectedId === block.id }]"
@click="selectPara(block.id)"
v-for="paragraph in paragraphs"
:key="paragraph.id"
:class="['para-block', 'manual-block', { selected: selectedId === paragraph.id }]"
@click="selectPara(paragraph.id)"
>
<span :class="['para-tag', block.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ blockTagLabel(block) }}
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 段落' : '手动段落' }}
</span>
<div class="manual-block-head">
<span class="manual-block-index">{{ block.sort_index }}</span>
<span class="manual-block-anchor" v-if="block.anchor_ref && block.anchor_ref !== block.title">
锚点{{ block.anchor_ref }}
<span class="manual-block-index">段落 {{ paragraph.sort_index }}</span>
<span class="manual-block-anchor" v-if="paragraph.anchor_title && paragraph.anchor_title !== paragraph.title">
原标题{{ paragraph.anchor_title }}
</span>
</div>
<div class="block-hero">
<div class="block-hero-type">{{ blockTypeLabel(block.block_type) }}</div>
<div class="block-hero-key" v-if="block.block_type === 'ai_slot' && block.placeholder_key">{{ renderBlockKey(block.placeholder_key) }}</div>
<div class="block-hero-key" v-else-if="block.block_type === 'variable' && block.variable_key">{{ renderBlockKey(block.variable_key) }}</div>
</div>
<div class="field-label">标题</div>
<a-input
v-model:value="block.title"
v-model:value="paragraph.title"
class="manual-title-input"
:placeholder="block.block_type === 'heading' ? '输入标题文本' : '输入导出时使用的标题'"
placeholder="输入导出时使用的标题"
@click.stop
/>
<div class="field-label">{{ block.block_type === 'variable' ? '变量默认值' : '正文' }}</div>
<div class="field-label">正文</div>
<a-textarea
v-model:value="block.content_json.text"
v-model:value="paragraph.content"
class="manual-content-input"
:rows="block.block_type === 'heading' ? 3 : 6"
:placeholder="contentPlaceholder(block)"
:rows="paragraph.write_mode === 'replace_heading_only' ? 3 : 6"
:placeholder="contentPlaceholder(paragraph)"
@click.stop
/>
<div class="manual-block-actions">
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'text')">后插正文块</a-button>
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'heading')">后插标题块</a-button>
<a-button size="small" type="primary" ghost @click.stop="insertSpecificBlockAfter(block, 'ai_slot')">后插 AI </a-button>
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'variable')">后插变量块</a-button>
<a-button size="small" :disabled="!canMoveUp(block)" @click.stop="moveUp(block)">上移</a-button>
<a-button size="small" :disabled="!canMoveDown(block)" @click.stop="moveDown(block)">下移</a-button>
<a-button size="small" danger :disabled="!canDeleteBlock(block)" @click.stop="handleDeleteParagraph(block)">删除当前块</a-button>
<a-button size="small" @click.stop="insertBlockAfter(paragraph, 'manual')">在后面新增固定块</a-button>
<a-button size="small" type="primary" ghost @click.stop="insertBlockAfter(paragraph, 'ai')">在后面新增 AI </a-button>
<a-button size="small" :disabled="!canMoveUp(paragraph)" @click.stop="moveUp(paragraph)">上移</a-button>
<a-button size="small" :disabled="!canMoveDown(paragraph)" @click.stop="moveDown(paragraph)">下移</a-button>
<a-button size="small" danger :disabled="!canDeleteBlock(paragraph)" @click.stop="handleDeleteParagraph(paragraph)">删除当前块</a-button>
</div>
<div class="manual-block-meta">
<span class="meta-item">编辑方式{{ block.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
<span class="meta-item">块类型{{ blockTypeLabel(block.block_type) }}</span>
<span class="meta-item">编辑方式{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
<span class="meta-item">写入方式{{ writeModeLabel(paragraph.write_mode) }}</span>
</div>
</div>
</template>
@ -156,41 +142,32 @@
<aside class="editor-right">
<div class="right-head">
<span>段落配置</span>
<span class="right-head-name">{{ selectedConfigItem?.title || '' }}</span>
<span class="right-head-name">{{ selectedPara?.title || '' }}</span>
</div>
<div class="right-scroll" v-if="selectedConfigItem">
<div class="right-scroll" v-if="selectedPara">
<div class="config-section">
<div class="config-section-title">生成设置</div>
<a-form layout="vertical">
<a-form-item label="编辑方式">
<a-select v-model:value="selectedConfigItem.edit_mode">
<a-select v-model:value="selectedPara.edit_mode">
<a-select-option value="ai">AI 生成</a-select-option>
<a-select-option value="manual">人工编辑</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="editorMode === 'paragraph'" label="写入方式">
<a-select v-model:value="selectedConfigItem.write_mode">
<a-form-item label="写入方式">
<a-select v-model:value="selectedPara.write_mode">
<a-select-option value="replace_section">替换标题下整段</a-select-option>
<a-select-option value="append_after_heading">标题下插入内容</a-select-option>
<a-select-option value="replace_heading_only">仅替换标题</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-else label="块类型">
<a-select v-model:value="selectedConfigItem.block_type">
<a-select-option value="heading">标题块</a-select-option>
<a-select-option value="text">正文块</a-select-option>
<a-select-option value="table">表格块</a-select-option>
<a-select-option value="ai_slot">AI </a-select-option>
<a-select-option value="variable">变量块</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="selectedConfigItem.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedConfigItem.model_id" allowClear placeholder="使用默认模型">
<a-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型">
<a-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="输出格式">
<a-select v-model:value="selectedConfigItem.output_format">
<a-select v-model:value="selectedPara.output_format">
<a-select-option value="text">正式报告段落</a-select-option>
<a-select-option value="table">表格形式</a-select-option>
<a-select-option value="mixed">混合内容</a-select-option>
@ -209,25 +186,19 @@
class="template-alert"
/>
<a-form layout="vertical">
<a-form-item :label="editorMode === 'paragraph' ? '原标题锚点' : '锚点引用'">
<a-input :value="currentAnchorValue(selectedConfigItem)" disabled />
<a-form-item label="原标题锚点">
<a-input :value="selectedPara.anchor_title || selectedPara.title" disabled />
</a-form-item>
<a-form-item label="导出标题">
<a-input v-model:value="selectedConfigItem.title" placeholder="输入导出时使用的标题" />
<a-input v-model:value="selectedPara.title" placeholder="输入导出时使用的标题" />
</a-form-item>
<a-form-item label="模板正文">
<a-textarea
v-model:value="contentProxy"
:rows="editorMode === 'paragraph' && selectedConfigItem.write_mode === 'replace_heading_only' ? 4 : 8"
:placeholder="contentPlaceholder(selectedConfigItem)"
v-model:value="selectedPara.content"
:rows="selectedPara.write_mode === 'replace_heading_only' ? 4 : 8"
:placeholder="contentPlaceholder(selectedPara)"
/>
</a-form-item>
<a-form-item v-if="editorMode === 'manual' && selectedConfigItem.block_type === 'ai_slot'" label="AI 占位键">
<a-input v-model:value="selectedConfigItem.placeholder_key" placeholder="例如 opening_summary" />
</a-form-item>
<a-form-item v-if="editorMode === 'manual' && selectedConfigItem.block_type === 'variable'" label="变量键">
<a-input v-model:value="selectedConfigItem.variable_key" placeholder="例如 report_date" />
</a-form-item>
</a-form>
</div>
@ -235,28 +206,28 @@
<div class="config-section-title">提示词与文件</div>
<div class="toggle-row">
<span>需要提示词</span>
<a-switch v-model:checked="selectedConfigItem.need_prompt" />
<a-switch v-model:checked="selectedPara.need_prompt" />
</div>
<a-textarea
v-if="selectedConfigItem.need_prompt"
v-model:value="selectedConfigItem.prompt_text"
v-if="selectedPara.need_prompt"
v-model:value="selectedPara.prompt_text"
:rows="4"
placeholder="在此输入提示词..."
/>
<div class="toggle-row file-row">
<span>需要参考文件</span>
<a-switch v-model:checked="selectedConfigItem.need_file" />
<a-switch v-model:checked="selectedPara.need_file" />
</div>
<a-textarea
v-if="selectedConfigItem.need_file"
v-model:value="selectedConfigItem.file_note"
v-if="selectedPara.need_file"
v-model:value="selectedPara.file_note"
:rows="3"
placeholder="提示用户上传什么文件"
/>
</div>
<a-button v-if="selectedConfigItem.edit_mode === 'ai'" type="primary" block @click="openTestModal">
<a-button v-if="selectedPara.edit_mode === 'ai'" type="primary" block @click="openTestModal">
立即测试
</a-button>
</div>
@ -273,8 +244,8 @@
<div v-if="testStep === 0">
<ReferenceFileSelector
v-model="testSelectedFiles"
:title="selectedConfigItem?.title || ''"
:description="selectedConfigItem?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
:title="selectedPara?.title || ''"
:description="selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
variant="full"
/>
@ -309,7 +280,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
@ -336,7 +307,6 @@ const store = useTemplateStore()
const modelStore = useModelStore()
const paragraphs = ref<any[]>([])
const blocks = ref<any[]>([])
const models = ref<any[]>([])
const selectedId = ref(0)
const templateName = ref('模板编辑')
@ -353,48 +323,16 @@ const testFileSummaries = ref<any[]>([])
const streamedText = ref('')
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
const selectedBlock = computed(() => blocks.value.find((item) => item.id === selectedId.value))
const currentItems = computed(() => (editorMode.value === 'paragraph' ? paragraphs.value : blocks.value))
const selectedConfigItem = computed(() => (editorMode.value === 'paragraph' ? selectedPara.value : selectedBlock.value))
const contentProxy = computed({
get() {
const current = selectedConfigItem.value
if (!current) return ''
return editorMode.value === 'paragraph' ? current.content || '' : current.content_json?.text || ''
},
set(value: string) {
const current = selectedConfigItem.value
if (!current) return
if (editorMode.value === 'paragraph') {
current.content = value
return
}
current.content_json = {
...(current.content_json || {}),
text: value,
}
},
})
function selectPara(id: number) {
selectedId.value = id
}
function activeCollection() {
return editorMode.value === 'paragraph' ? paragraphs.value : blocks.value
}
function normalizeSortIndex() {
const items = activeCollection()
const normalized = items.map((item, index) => ({
paragraphs.value = paragraphs.value.map((item, index) => ({
...item,
sort_index: index + 1,
}))
if (editorMode.value === 'paragraph') {
paragraphs.value = normalized
} else {
blocks.value = normalized
}
}
function writeModeLabel(mode: string) {
@ -403,133 +341,43 @@ function writeModeLabel(mode: string) {
return '替换标题下整段'
}
function currentAnchorValue(item: any) {
if (!item) return ''
return editorMode.value === 'paragraph' ? item.anchor_title || item.title : item.anchor_ref || item.title
function listTitle(paragraph: any) {
if (!paragraph?.anchor_title || paragraph.anchor_title === paragraph.title) {
return paragraph?.title || '未命名段落'
}
function listTitle(item: any) {
const anchorValue = editorMode.value === 'paragraph' ? item?.anchor_title : item?.anchor_ref
if (!anchorValue || anchorValue === item?.title) {
return item?.title || '未命名段落'
}
return `${item.title || '未命名块'}(归属 ${anchorValue}`
}
function blockTypeLabel(type: string) {
if (type === 'heading') return '标题块'
if (type === 'table') return '表格块'
if (type === 'ai_slot') return 'AI 块'
if (type === 'variable') return '变量块'
return '正文块'
}
function blockTagLabel(block: any) {
if (block.block_type === 'heading') return '标题块'
if (block.block_type === 'table') return '表格块'
if (block.block_type === 'variable') return '变量块'
return block.edit_mode === 'ai' || block.block_type === 'ai_slot' ? 'AI 块' : '手动块'
}
function renderBlockKey(key: string) {
return `{{ ${key} }}`
}
function blockCardClass(block: any) {
if (block.block_type === 'heading') return 'manual-block-heading'
if (block.block_type === 'ai_slot') return 'manual-block-ai'
if (block.block_type === 'variable') return 'manual-block-variable'
if (block.block_type === 'table') return 'manual-block-table'
return 'manual-block-text'
}
function createVisualBlock(type: 'heading' | 'text' | 'ai_slot' | 'variable') {
const now = Date.now() + Math.floor(Math.random() * 1000)
const titleMap: Record<string, string> = {
heading: '新标题块',
text: '新正文块',
ai_slot: '新 AI 块',
variable: '新变量块',
}
return {
id: -now,
template_id: Number(route.params.id),
source_paragraph_id: null,
parent_block_id: null,
sort_index: blocks.value.length + 1,
block_type: type,
anchor_ref: '',
title: titleMap[type],
content_json: { text: type === 'ai_slot' ? '请在这里描述要由 AI 生成的内容。' : '' },
style_json: '{}',
edit_mode: type === 'ai_slot' ? 'ai' : 'manual',
placeholder_key: type === 'ai_slot' ? `ai_block_${Math.abs(now)}` : '',
variable_key: type === 'variable' ? `variable_${Math.abs(now)}` : '',
default_value: '',
model_id: null,
need_prompt: type === 'ai_slot',
prompt_text: '',
need_file: false,
file_note: '',
output_format: 'text',
}
}
function appendBlock(type: 'heading' | 'text' | 'ai_slot' | 'variable') {
const block = createVisualBlock(type)
blocks.value.push(block)
editorMode.value = 'manual'
normalizeSortIndex()
nextTick(() => {
selectedId.value = block.id
})
}
function insertSpecificBlockAfter(sourceBlock: any, type: 'heading' | 'text' | 'ai_slot' | 'variable') {
const index = blocks.value.findIndex((item) => item === sourceBlock)
if (index < 0) return
const block = createVisualBlock(type)
block.anchor_ref = sourceBlock.anchor_ref || sourceBlock.title || ''
blocks.value.splice(index + 1, 0, block)
normalizeSortIndex()
nextTick(() => {
selectedId.value = block.id
})
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title}`
}
function canDeleteBlock(_paragraph: any) {
return activeCollection().length > 1
return paragraphs.value.length > 1
}
function canMoveUp(paragraph: any) {
const index = activeCollection().findIndex((item) => item === paragraph)
const index = paragraphs.value.findIndex((item) => item === paragraph)
return index > 0
}
function canMoveDown(paragraph: any) {
const items = activeCollection()
const index = items.findIndex((item) => item === paragraph)
return index >= 0 && index < items.length - 1
const index = paragraphs.value.findIndex((item) => item === paragraph)
return index >= 0 && index < paragraphs.value.length - 1
}
function moveUp(paragraph: any) {
const items = activeCollection()
const index = items.findIndex((item) => item === paragraph)
const index = paragraphs.value.findIndex((item) => item === paragraph)
if (index <= 0) return
const temp = items[index]
items[index] = items[index - 1]
items[index - 1] = temp
const temp = paragraphs.value[index]
paragraphs.value[index] = paragraphs.value[index - 1]
paragraphs.value[index - 1] = temp
normalizeSortIndex()
autoSaveParagraphs()
}
function moveDown(paragraph: any) {
const items = activeCollection()
const index = items.findIndex((item) => item === paragraph)
if (index < 0 || index >= items.length - 1) return
const temp = items[index]
items[index] = items[index + 1]
items[index + 1] = temp
const index = paragraphs.value.findIndex((item) => item === paragraph)
if (index < 0 || index >= paragraphs.value.length - 1) return
const temp = paragraphs.value[index]
paragraphs.value[index] = paragraphs.value[index + 1]
paragraphs.value[index + 1] = temp
normalizeSortIndex()
autoSaveParagraphs()
}
@ -568,37 +416,10 @@ async function autoSaveParagraphs() {
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,
}))
try {
console.log('[autoSave] sending paragraphs:', data.map(p => ({ id: p.id, title: p.title })))
const response: any = await templateApi.saveParagraphs(templateId, {
save_mode: editorMode.value,
paragraphs: data,
blocks: editorMode.value === 'manual' ? blockData : [],
})
await templateApi.saveParagraphs(templateId, { paragraphs: data })
store.paragraphs = paragraphs.value as any
store.blocks = response.data?.blocks || blocks.value
blocks.value = store.blocks as any
console.log('[autoSave] save success')
} catch (e: any) {
console.error('[autoSave] save failed:', e)
@ -607,7 +428,6 @@ async function autoSaveParagraphs() {
}
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
if (editorMode.value === 'paragraph') {
const index = paragraphs.value.findIndex((item) => item === sourceParagraph)
if (index < 0) return
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_title || '未命名段落'
@ -635,38 +455,6 @@ function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
nextTick(() => {
selectedId.value = newBlock.id
})
return
}
const index = blocks.value.findIndex((item) => item === sourceParagraph)
if (index < 0) return
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_ref || '未命名块'
const newBlock = {
id: -Date.now() - Math.floor(Math.random() * 1000),
template_id: Number(route.params.id),
source_paragraph_id: sourceParagraph.source_paragraph_id || null,
parent_block_id: null,
sort_index: sourceParagraph.sort_index + 1,
block_type: editMode === 'ai' ? 'ai_slot' : 'text',
anchor_ref: sourceParagraph.anchor_ref || blockTitle,
title: blockTitle,
content_json: { text: '' },
style_json: sourceParagraph.style_json || '{}',
edit_mode: editMode,
placeholder_key: '',
variable_key: '',
default_value: '',
model_id: editMode === 'ai' ? sourceParagraph.model_id ?? null : null,
need_prompt: editMode === 'ai',
prompt_text: editMode === 'ai' ? sourceParagraph.prompt_text || '' : '',
need_file: false,
file_note: '',
output_format: 'text',
}
blocks.value.splice(index + 1, 0, newBlock)
normalizeSortIndex()
nextTick(() => {
selectedId.value = newBlock.id
})
}
function removeBlock(paragraph: any) {
@ -674,22 +462,16 @@ function removeBlock(paragraph: any) {
message.warning('至少保留一个段落')
return
}
const items = activeCollection()
const index = items.findIndex((item) => item === paragraph)
const index = paragraphs.value.findIndex((item) => item === paragraph)
if (index < 0) return
items.splice(index, 1)
paragraphs.value.splice(index, 1)
normalizeSortIndex()
const next = items[index] || items[index - 1] || items[0]
const next = paragraphs.value[index] || paragraphs.value[index - 1] || paragraphs.value[0]
selectedId.value = next?.id || 0
autoSaveParagraphs()
}
function contentPlaceholder(paragraph: any) {
if (editorMode.value === 'manual') {
if (paragraph?.block_type === 'heading') return '标题块通常只维护标题文本。'
if (paragraph?.block_type === 'variable') return '这里可以填写变量默认值或展示文本。'
if (paragraph?.block_type === 'ai_slot') return '这里可填写 AI 块的上下文说明或默认内容。'
}
if (paragraph?.edit_mode === 'manual') {
return paragraph?.write_mode === 'replace_heading_only'
? '仅替换标题时,这里的正文仅作为备注保留,不会覆盖原文。'
@ -704,13 +486,13 @@ function contentPlaceholder(paragraph: any) {
async function saveTemplate() {
const templateId = Number(route.params.id)
store.paragraphs = paragraphs.value as any
store.blocks = blocks.value as any
await store.save(templateId)
const template = await store.fetchOne(templateId)
templateName.value = template.name
paragraphs.value = store.paragraphs as any
blocks.value = store.blocks as any
syncSelectionForMode()
if (selectedId.value <= 0 && paragraphs.value.length) {
selectedId.value = paragraphs.value[0].id
}
message.success('模板配置已保存')
}
@ -760,13 +542,8 @@ function renderTestResult(content: any) {
}
async function startTest() {
if (!selectedConfigItem.value) return
const paragraphId = editorMode.value === 'paragraph' ? selectedPara.value?.id : selectedBlock.value?.source_paragraph_id
if (!paragraphId) {
message.warning('当前块还没有绑定可测试的原始段落,请先保存模板后再测试')
return
}
if (selectedConfigItem.value.need_file && !testSelectedFiles.value.length) {
if (!selectedPara.value) return
if (selectedPara.value.need_file && !testSelectedFiles.value.length) {
message.warning('请先上传至少一个参考文件')
return
}
@ -780,17 +557,17 @@ async function startTest() {
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
const templateId = Number(route.params.id)
const currentModel = models.value.find((item) => item.id === selectedConfigItem.value.model_id)
const currentModel = models.value.find((item) => item.id === selectedPara.value.model_id)
if (currentModel?.supports_streaming) {
testStatusText.value = '正在流式接收模型返回内容...'
streamedText.value = ''
await runStreamingTest(templateId, filePaths, paragraphId)
await runStreamingTest(templateId, filePaths)
} else {
const response: any = await generateApi.test({
paragraph_id: paragraphId,
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedConfigItem.value.prompt_text || '',
model_id: selectedConfigItem.value.model_id || 0,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: filePaths,
})
@ -807,15 +584,15 @@ async function startTest() {
}
}
async function runStreamingTest(templateId: number, filePaths: string[], paragraphId: number) {
async function runStreamingTest(templateId: number, filePaths: string[]) {
const response = await fetch(generateApi.testStream(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paragraph_id: paragraphId,
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedConfigItem.value.prompt_text || '',
model_id: selectedConfigItem.value.model_id || 0,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: filePaths,
}),
})
@ -869,30 +646,16 @@ async function runStreamingTest(templateId: number, filePaths: string[], paragra
}
}
function syncSelectionForMode() {
const items = currentItems.value
if (!items.length) {
selectedId.value = 0
return
}
if (!items.some((item) => item.id === selectedId.value)) {
selectedId.value = items[0].id
}
}
watch(editorMode, () => {
syncSelectionForMode()
})
onMounted(async () => {
const id = Number(route.params.id)
const template = await store.fetchOne(id)
templateName.value = template.name
paragraphs.value = store.paragraphs as any
blocks.value = store.blocks as any
await modelStore.fetchList()
models.value = modelStore.models as any
syncSelectionForMode()
if (paragraphs.value.length) {
selectedId.value = paragraphs.value[0].id
}
})
</script>
@ -1036,12 +799,6 @@ onMounted(async () => {
flex-shrink: 0;
}
.doc-edit-page-manual {
background:
linear-gradient(180deg, rgba(252, 248, 239, 0.7), rgba(255, 255, 255, 0) 120px),
#fff;
}
.para-block {
position: relative;
padding: 8px;
@ -1111,31 +868,6 @@ onMounted(async () => {
margin-bottom: 16px;
border: 1px solid #e7e9ee;
border-radius: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 249, 252, 0.98));
}
.manual-block-heading {
border-color: #1f2937;
background: linear-gradient(180deg, #f8fafc, #ffffff 42%);
}
.manual-block-text {
border-color: #d7dde7;
}
.manual-block-ai {
border-color: #5b5bd6;
background: linear-gradient(180deg, rgba(238, 238, 251, 0.95), rgba(255, 255, 255, 0.98));
}
.manual-block-variable {
border-color: #0f766e;
background: linear-gradient(180deg, rgba(236, 253, 245, 0.96), rgba(255, 255, 255, 0.98));
}
.manual-block-table {
border-color: #a16207;
background: linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(255, 255, 255, 0.98));
}
.manual-block-head {
@ -1157,31 +889,6 @@ onMounted(async () => {
color: #7b8190;
}
.block-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.block-hero-type {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #475569;
}
.block-hero-key {
padding: 4px 10px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.06);
font-size: 12px;
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.field-label {
margin-bottom: 8px;
font-size: 12px;
@ -1317,31 +1024,6 @@ onMounted(async () => {
color: #e68a00;
}
.pli-badge.block-heading {
background: #e5e7eb;
color: #111827;
}
.pli-badge.block-text {
background: #eef2f7;
color: #475569;
}
.pli-badge.block-ai_slot {
background: #eeeefb;
color: #5b5bd6;
}
.pli-badge.block-variable {
background: #dcfce7;
color: #0f766e;
}
.pli-badge.block-table {
background: #fef3c7;
color: #a16207;
}
.pli-actions {
display: none;
gap: 2px;