updates
This commit is contained in:
parent
6f019610f8
commit
4c9924a5e7
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(item: any) {
|
||||
const anchorValue = editorMode.value === 'paragraph' ? item?.anchor_title : item?.anchor_ref
|
||||
if (!anchorValue || anchorValue === item?.title) {
|
||||
return item?.title || '未命名段落'
|
||||
function listTitle(paragraph: any) {
|
||||
if (!paragraph?.anchor_title || paragraph.anchor_title === paragraph.title) {
|
||||
return paragraph?.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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue