updates
This commit is contained in:
parent
6f019610f8
commit
4c9924a5e7
|
|
@ -22,7 +22,6 @@ async def get_db():
|
||||||
async def init_db():
|
async def init_db():
|
||||||
from models.template import Template
|
from models.template import Template
|
||||||
from models.paragraph import Paragraph
|
from models.paragraph import Paragraph
|
||||||
from models.template_block import TemplateBlock
|
|
||||||
from models.ai_model import AiModel
|
from models.ai_model import AiModel
|
||||||
from models.document import Document
|
from models.document import Document
|
||||||
from models.generation_log import GenerationLog
|
from models.generation_log import GenerationLog
|
||||||
|
|
@ -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"))
|
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:
|
if "write_mode" not in paragraph_columns:
|
||||||
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'"))
|
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.template import Template
|
||||||
from models.paragraph import Paragraph
|
from models.paragraph import Paragraph
|
||||||
from models.template_block import TemplateBlock
|
|
||||||
from models.ai_model import AiModel
|
from models.ai_model import AiModel
|
||||||
from models.document import Document
|
from models.document import Document
|
||||||
from models.generation_log import GenerationLog
|
from models.generation_log import GenerationLog
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from models.document import Document
|
||||||
from models.generation_log import GenerationLog
|
from models.generation_log import GenerationLog
|
||||||
from models.paragraph import Paragraph
|
from models.paragraph import Paragraph
|
||||||
from models.template import Template
|
from models.template import Template
|
||||||
from models.template_block import TemplateBlock
|
|
||||||
from services.document_export import export_document_bytes
|
from services.document_export import export_document_bytes
|
||||||
from services.minio_client import (
|
from services.minio_client import (
|
||||||
download_object_bytes,
|
download_object_bytes,
|
||||||
|
|
@ -27,46 +26,6 @@ from services.minio_client import (
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _block_text_content(block: TemplateBlock) -> str:
|
|
||||||
try:
|
|
||||||
payload = json.loads(block.content_json or "{}")
|
|
||||||
except Exception:
|
|
||||||
payload = {}
|
|
||||||
return payload.get("text") or block.default_value or ""
|
|
||||||
|
|
||||||
|
|
||||||
def _block_table_content(block: TemplateBlock) -> dict:
|
|
||||||
try:
|
|
||||||
payload = json.loads(block.content_json or "{}")
|
|
||||||
except Exception:
|
|
||||||
payload = {}
|
|
||||||
return payload.get("table") or {}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_block_export_content(block: TemplateBlock) -> dict:
|
|
||||||
if block.block_type == "table":
|
|
||||||
table_data = _block_table_content(block)
|
|
||||||
matrix = table_data.get("data") or []
|
|
||||||
headers = matrix[0] if matrix else []
|
|
||||||
rows = matrix[1:] if len(matrix) > 1 else []
|
|
||||||
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
|
|
||||||
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
|
|
||||||
modes: list[str] = []
|
|
||||||
anchor_counter: dict[str, int] = {}
|
|
||||||
for block in blocks:
|
|
||||||
if block.block_type == "heading":
|
|
||||||
modes.append("replace_heading_only")
|
|
||||||
continue
|
|
||||||
anchor = (block.anchor_ref or block.title or "").strip()
|
|
||||||
seen = anchor_counter.get(anchor, 0)
|
|
||||||
modes.append("replace_section" if seen == 0 else "append_after_heading")
|
|
||||||
anchor_counter[anchor] = seen + 1
|
|
||||||
return modes
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{document_id}/docx")
|
@router.get("/{document_id}/docx")
|
||||||
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
document = await db.get(Document, document_id)
|
document = await db.get(Document, document_id)
|
||||||
|
|
@ -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_bucket, template_object = split_bucket_path(template.file_path)
|
||||||
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
|
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
|
||||||
|
|
||||||
block_result = await db.execute(
|
|
||||||
select(TemplateBlock)
|
|
||||||
.where(TemplateBlock.template_id == template.id)
|
|
||||||
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
|
|
||||||
)
|
|
||||||
blocks = block_result.scalars().all()
|
|
||||||
|
|
||||||
log_result = await db.execute(
|
|
||||||
select(GenerationLog).where(GenerationLog.document_id == document_id)
|
|
||||||
)
|
|
||||||
generation_logs = log_result.scalars().all()
|
|
||||||
log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs}
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
if blocks:
|
|
||||||
write_modes = _resolve_block_write_modes(blocks)
|
|
||||||
for block, write_mode in zip(blocks, write_modes):
|
|
||||||
generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
|
|
||||||
content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block)
|
|
||||||
logs.append(
|
|
||||||
{
|
|
||||||
"anchor_title": block.anchor_ref or block.title,
|
|
||||||
"title": block.title,
|
|
||||||
"write_mode": write_mode,
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(GenerationLog, Paragraph)
|
select(GenerationLog, Paragraph)
|
||||||
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
||||||
.where(GenerationLog.document_id == document_id)
|
.where(GenerationLog.document_id == document_id)
|
||||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||||
)
|
)
|
||||||
|
logs = []
|
||||||
for log, paragraph in result.all():
|
for log, paragraph in result.all():
|
||||||
logs.append(
|
logs.append(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -16,10 +15,8 @@ from models.document import Document
|
||||||
from models.generation_log import GenerationLog
|
from models.generation_log import GenerationLog
|
||||||
from models.paragraph import Paragraph
|
from models.paragraph import Paragraph
|
||||||
from models.template import Template
|
from models.template import Template
|
||||||
from models.template_block import TemplateBlock
|
|
||||||
from schemas.schemas import Response, TemplateSave
|
from schemas.schemas import Response, TemplateSave
|
||||||
from services.document_export import export_document_bytes
|
from services.minio_client import minio_client
|
||||||
from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes
|
|
||||||
from services.template_parser import parse_template
|
from services.template_parser import parse_template
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -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("")
|
@router.get("")
|
||||||
async def list_templates(
|
async def list_templates(
|
||||||
page: int = Query(1, ge=1),
|
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)
|
.where(Paragraph.template_id == template_id)
|
||||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||||
)
|
)
|
||||||
paragraph_rows = result.scalars().all()
|
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
|
||||||
paragraphs = [_serialize_paragraph(item) for item in paragraph_rows]
|
|
||||||
block_rows = await _load_blocks(db, template_id)
|
|
||||||
if not block_rows and paragraph_rows:
|
|
||||||
block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows)
|
|
||||||
await db.commit()
|
|
||||||
blocks = [_serialize_block(item) for item in block_rows]
|
|
||||||
payload = _serialize_template(template)
|
payload = _serialize_template(template)
|
||||||
payload["paragraphs"] = paragraphs
|
payload["paragraphs"] = paragraphs
|
||||||
payload["blocks"] = blocks
|
|
||||||
return Response(data=payload)
|
return Response(data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -435,35 +161,19 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
|
||||||
style_json=item.style_json,
|
style_json=item.style_json,
|
||||||
is_table=item.is_table,
|
is_table=item.is_table,
|
||||||
table_json=item.table_json,
|
table_json=item.table_json,
|
||||||
edit_mode=item.edit_mode,
|
edit_mode="manual",
|
||||||
write_mode=item.write_mode,
|
write_mode=item.write_mode,
|
||||||
need_prompt=item.edit_mode == "ai",
|
|
||||||
output_format=item.output_format,
|
|
||||||
)
|
)
|
||||||
db.add(paragraph)
|
db.add(paragraph)
|
||||||
paragraph_rows.append(paragraph)
|
paragraph_rows.append(paragraph)
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
existing_blocks = await _load_blocks(db, template.id)
|
|
||||||
for block in existing_blocks:
|
|
||||||
await db.delete(block)
|
|
||||||
await db.flush()
|
|
||||||
block_rows: list[TemplateBlock] = []
|
|
||||||
for item, paragraph in zip(parsed_items, paragraph_rows):
|
|
||||||
block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id))
|
|
||||||
db.add(block)
|
|
||||||
block_rows.append(block)
|
|
||||||
await db.flush()
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(template)
|
await db.refresh(template)
|
||||||
for paragraph in paragraph_rows:
|
for paragraph in paragraph_rows:
|
||||||
await db.refresh(paragraph)
|
await db.refresh(paragraph)
|
||||||
for block in block_rows:
|
|
||||||
await db.refresh(block)
|
|
||||||
|
|
||||||
payload = _serialize_template(template)
|
payload = _serialize_template(template)
|
||||||
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
|
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
|
||||||
payload["blocks"] = [_serialize_block(item) for item in block_rows]
|
|
||||||
return Response(data=payload)
|
return Response(data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -514,24 +224,10 @@ async def save_template_paragraphs(
|
||||||
paragraph.file_note = config.file_note
|
paragraph.file_note = config.file_note
|
||||||
paragraph.output_format = config.output_format
|
paragraph.output_format = config.output_format
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
refreshed_result = await db.execute(
|
|
||||||
select(Paragraph)
|
|
||||||
.where(Paragraph.template_id == template_id)
|
|
||||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
|
||||||
)
|
|
||||||
refreshed_paragraphs = refreshed_result.scalars().all()
|
|
||||||
if body.save_mode == "manual" and body.blocks:
|
|
||||||
await _save_blocks(db, template_id, body.blocks)
|
|
||||||
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
|
|
||||||
else:
|
|
||||||
await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs)
|
|
||||||
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
|
|
||||||
template.paragraph_count = len(refreshed_paragraphs)
|
|
||||||
await _write_template_snapshot_to_docx(db, template)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)]
|
template.paragraph_count = len(body.paragraphs)
|
||||||
return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks})
|
await db.commit()
|
||||||
|
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{template_id}")
|
@router.delete("/{template_id}")
|
||||||
|
|
|
||||||
|
|
@ -43,32 +43,8 @@ class ParagraphConfig(BaseModel):
|
||||||
file_note: str = ""
|
file_note: str = ""
|
||||||
output_format: str = "text"
|
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):
|
class TemplateSave(BaseModel):
|
||||||
save_mode: str = "paragraph"
|
|
||||||
paragraphs: list[ParagraphConfig] = []
|
paragraphs: list[ParagraphConfig] = []
|
||||||
blocks: list[TemplateBlockConfig] = []
|
|
||||||
|
|
||||||
# 模型
|
# 模型
|
||||||
class AiModelCreate(BaseModel):
|
class AiModelCreate(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -69,77 +69,6 @@ def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors:
|
||||||
_delete_block(block)
|
_delete_block(block)
|
||||||
|
|
||||||
|
|
||||||
def _ordered_unique_anchors(logs: list[dict]) -> list[str]:
|
|
||||||
ordered: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in logs:
|
|
||||||
anchor = (item.get("anchor_title") or item.get("title") or "").strip()
|
|
||||||
if not anchor or anchor in seen:
|
|
||||||
continue
|
|
||||||
seen.add(anchor)
|
|
||||||
ordered.append(anchor)
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
|
|
||||||
def _reorder_heading_sections(document: DocumentObject, ordered_anchors: list[str]):
|
|
||||||
body = document.element.body
|
|
||||||
elements = list(body.iterchildren())
|
|
||||||
pre_heading: list = []
|
|
||||||
sections: list[tuple[str, list]] = []
|
|
||||||
found_heading = False
|
|
||||||
index = 0
|
|
||||||
|
|
||||||
while index < len(elements):
|
|
||||||
child = elements[index]
|
|
||||||
if isinstance(child, CT_P):
|
|
||||||
paragraph = Paragraph(child, document)
|
|
||||||
if _is_heading(paragraph):
|
|
||||||
found_heading = True
|
|
||||||
anchor = paragraph.text.strip()
|
|
||||||
section_elements = [child]
|
|
||||||
index += 1
|
|
||||||
while index < len(elements):
|
|
||||||
current = elements[index]
|
|
||||||
if isinstance(current, CT_P):
|
|
||||||
current_paragraph = Paragraph(current, document)
|
|
||||||
if _is_heading(current_paragraph):
|
|
||||||
break
|
|
||||||
section_elements.append(current)
|
|
||||||
index += 1
|
|
||||||
sections.append((anchor, section_elements))
|
|
||||||
continue
|
|
||||||
if not found_heading:
|
|
||||||
pre_heading.append(child)
|
|
||||||
index += 1
|
|
||||||
|
|
||||||
if not sections:
|
|
||||||
return
|
|
||||||
|
|
||||||
section_map: dict[str, list[list]] = {}
|
|
||||||
for anchor, section_elements in sections:
|
|
||||||
section_map.setdefault(anchor, []).append(section_elements)
|
|
||||||
|
|
||||||
all_section_elements = [element for _, section_elements in sections for element in section_elements]
|
|
||||||
for element in all_section_elements:
|
|
||||||
parent = element.getparent()
|
|
||||||
if parent is not None:
|
|
||||||
parent.remove(element)
|
|
||||||
|
|
||||||
sect_pr = None
|
|
||||||
for child in list(body.iterchildren()):
|
|
||||||
if not isinstance(child, (CT_P, CT_Tbl)):
|
|
||||||
sect_pr = child
|
|
||||||
break
|
|
||||||
|
|
||||||
for anchor in ordered_anchors:
|
|
||||||
for section_elements in section_map.pop(anchor, []):
|
|
||||||
for element in section_elements:
|
|
||||||
if sect_pr is not None:
|
|
||||||
sect_pr.addprevious(element)
|
|
||||||
else:
|
|
||||||
body.append(element)
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_paragraph(paragraph: Paragraph):
|
def _clear_paragraph(paragraph: Paragraph):
|
||||||
element = paragraph._element
|
element = paragraph._element
|
||||||
for child in list(element):
|
for child in list(element):
|
||||||
|
|
@ -440,8 +369,6 @@ def _replace_section_group(
|
||||||
|
|
||||||
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
||||||
document = Document(BytesIO(template_bytes))
|
document = Document(BytesIO(template_bytes))
|
||||||
ordered_anchors = _ordered_unique_anchors(logs)
|
|
||||||
_reorder_heading_sections(document, ordered_anchors)
|
|
||||||
|
|
||||||
referenced_anchors: set[str] = set()
|
referenced_anchors: set[str] = set()
|
||||||
for item in logs:
|
for item in logs:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
@ -23,12 +22,6 @@ class ParsedParagraph:
|
||||||
is_table: bool
|
is_table: bool
|
||||||
table_json: str
|
table_json: str
|
||||||
write_mode: str
|
write_mode: str
|
||||||
block_type: str = "text"
|
|
||||||
placeholder_key: str = ""
|
|
||||||
variable_key: str = ""
|
|
||||||
default_value: str = ""
|
|
||||||
edit_mode: str = "manual"
|
|
||||||
output_format: str = "text"
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
|
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
|
||||||
|
|
@ -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]:
|
def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||||
document = Document(file_path)
|
document = Document(file_path)
|
||||||
parsed: list[ParsedParagraph] = []
|
parsed: list[ParsedParagraph] = []
|
||||||
current_heading: str | None = None
|
current_item: ParsedParagraph | None = None
|
||||||
current_heading_style_json = "{}"
|
|
||||||
loose_table_count = 0
|
loose_table_count = 0
|
||||||
body_block_count = 0
|
|
||||||
preface_count = 0
|
|
||||||
|
|
||||||
for block in _iter_block_items(document):
|
for block in _iter_block_items(document):
|
||||||
if isinstance(block, Paragraph):
|
if isinstance(block, Paragraph):
|
||||||
|
|
@ -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 "")
|
level = _heading_level(block.style.name if block.style is not None else "")
|
||||||
if level is not None:
|
if level is not None:
|
||||||
current_heading = text
|
current_item = ParsedParagraph(
|
||||||
current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False)
|
|
||||||
body_block_count = 0
|
|
||||||
parsed.append(ParsedParagraph(
|
|
||||||
sort_index=len(parsed) + 1,
|
sort_index=len(parsed) + 1,
|
||||||
anchor_title=text,
|
anchor_title=text,
|
||||||
title=text,
|
title=text,
|
||||||
content="",
|
content="",
|
||||||
style_json=current_heading_style_json,
|
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
|
||||||
is_table=False,
|
is_table=False,
|
||||||
table_json="{}",
|
table_json="{}",
|
||||||
write_mode="replace_heading_only",
|
write_mode="replace_section",
|
||||||
block_type="heading",
|
)
|
||||||
edit_mode="manual",
|
parsed.append(current_item)
|
||||||
output_format="text",
|
|
||||||
))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
block_type, placeholder_key, variable_key = _classify_placeholder(text)
|
if current_item is None:
|
||||||
if current_heading is None:
|
current_item = ParsedParagraph(
|
||||||
preface_count += 1
|
|
||||||
anchor_title = f"文档起始_{preface_count}"
|
|
||||||
title = _build_block_title(text, anchor_title)
|
|
||||||
write_mode = "replace_section"
|
|
||||||
else:
|
|
||||||
body_block_count += 1
|
|
||||||
anchor_title = current_heading
|
|
||||||
title = _build_block_title(text, f"{current_heading}-正文{body_block_count}")
|
|
||||||
write_mode = "append_after_heading"
|
|
||||||
|
|
||||||
parsed.append(ParsedParagraph(
|
|
||||||
sort_index=len(parsed) + 1,
|
sort_index=len(parsed) + 1,
|
||||||
anchor_title=anchor_title,
|
anchor_title="未命名段落",
|
||||||
title=title,
|
title="未命名段落",
|
||||||
content=text,
|
content=text,
|
||||||
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
||||||
is_table=False,
|
is_table=False,
|
||||||
table_json="{}",
|
table_json="{}",
|
||||||
write_mode=write_mode,
|
write_mode="replace_section",
|
||||||
block_type=block_type,
|
)
|
||||||
placeholder_key=placeholder_key,
|
parsed.append(current_item)
|
||||||
variable_key=variable_key,
|
else:
|
||||||
default_value="" if variable_key else text,
|
current_item.content = "\n".join(filter(None, [current_item.content, text]))
|
||||||
edit_mode="ai" if block_type == "ai_slot" else "manual",
|
|
||||||
output_format="text",
|
|
||||||
))
|
|
||||||
else:
|
else:
|
||||||
table_data = _extract_table_data(block)
|
table_data = _extract_table_data(block)
|
||||||
table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列"
|
table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列"
|
||||||
if current_heading is None:
|
if current_item is None:
|
||||||
loose_table_count += 1
|
loose_table_count += 1
|
||||||
anchor_title = f"表格_{loose_table_count}"
|
current_item = ParsedParagraph(
|
||||||
title = anchor_title
|
|
||||||
write_mode = "replace_section"
|
|
||||||
else:
|
|
||||||
body_block_count += 1
|
|
||||||
anchor_title = current_heading
|
|
||||||
title = f"{current_heading}-表格{body_block_count}"
|
|
||||||
write_mode = "append_after_heading"
|
|
||||||
|
|
||||||
parsed.append(ParsedParagraph(
|
|
||||||
sort_index=len(parsed) + 1,
|
sort_index=len(parsed) + 1,
|
||||||
anchor_title=anchor_title,
|
anchor_title=f"表格_{loose_table_count}",
|
||||||
title=title,
|
title=f"表格_{loose_table_count}",
|
||||||
content=table_text,
|
content=table_text,
|
||||||
style_json=current_heading_style_json if current_heading else "{}",
|
style_json="{}",
|
||||||
is_table=True,
|
is_table=True,
|
||||||
table_json=json.dumps(table_data, ensure_ascii=False),
|
table_json=json.dumps(table_data, ensure_ascii=False),
|
||||||
write_mode=write_mode,
|
write_mode="replace_section",
|
||||||
block_type="table",
|
)
|
||||||
default_value=table_text,
|
parsed.append(current_item)
|
||||||
edit_mode="manual",
|
else:
|
||||||
output_format="table",
|
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
|
return parsed
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,19 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { templateApi } from '@/api/template'
|
import { templateApi } from '@/api/template'
|
||||||
import type { Template, Paragraph, TemplateBlock } from '@/types'
|
import type { Template, Paragraph } from '@/types'
|
||||||
|
|
||||||
export const useTemplateStore = defineStore('template', () => {
|
export const useTemplateStore = defineStore('template', () => {
|
||||||
const templates = ref<Template[]>([])
|
const templates = ref<Template[]>([])
|
||||||
const currentTemplate = ref<Template | null>(null)
|
const currentTemplate = ref<Template | null>(null)
|
||||||
const paragraphs = ref<Paragraph[]>([])
|
const paragraphs = ref<Paragraph[]>([])
|
||||||
const blocks = ref<TemplateBlock[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } }
|
async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } }
|
||||||
async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; blocks.value = r.data?.blocks || []; return r.data }
|
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 || []; blocks.value = r.data?.blocks || []; return r.data }
|
async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
|
||||||
async function save(id: number) {
|
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 }) }
|
||||||
const data = paragraphs.value.map((p, index) => ({ id: p.id, sort_index: index + 1, anchor_title: p.anchor_title, title: p.title, content: p.content, edit_mode: p.edit_mode, write_mode: p.write_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format }))
|
|
||||||
const blockData = blocks.value.map((block, index) => ({
|
|
||||||
id: block.id,
|
|
||||||
source_paragraph_id: block.source_paragraph_id,
|
|
||||||
parent_block_id: block.parent_block_id,
|
|
||||||
sort_index: index + 1,
|
|
||||||
block_type: block.block_type,
|
|
||||||
anchor_ref: block.anchor_ref,
|
|
||||||
title: block.title,
|
|
||||||
content_json: block.content_json,
|
|
||||||
style_json: block.style_json,
|
|
||||||
edit_mode: block.edit_mode,
|
|
||||||
placeholder_key: block.placeholder_key,
|
|
||||||
variable_key: block.variable_key,
|
|
||||||
default_value: block.default_value,
|
|
||||||
model_id: block.model_id,
|
|
||||||
need_prompt: block.need_prompt,
|
|
||||||
prompt_text: block.prompt_text,
|
|
||||||
need_file: block.need_file,
|
|
||||||
file_note: block.file_note,
|
|
||||||
output_format: block.output_format,
|
|
||||||
}))
|
|
||||||
const r: any = await templateApi.saveParagraphs(id, { save_mode: 'manual', paragraphs: data, blocks: blockData })
|
|
||||||
blocks.value = r.data?.blocks || blocks.value
|
|
||||||
}
|
|
||||||
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
|
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
|
||||||
|
|
||||||
return { templates, currentTemplate, paragraphs, blocks, loading, fetchList, fetchOne, upload, save, remove }
|
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export interface Template {
|
export interface Template {
|
||||||
id: number; name: string; description: string; file_path: string
|
id: number; name: string; description: string; file_path: string
|
||||||
paragraph_count: number; status: string; created_at: string; updated_at: string
|
paragraph_count: number; status: string; created_at: string; updated_at: string
|
||||||
blocks?: TemplateBlock[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Paragraph {
|
export interface Paragraph {
|
||||||
|
|
@ -14,29 +13,6 @@ export interface Paragraph {
|
||||||
output_format: 'text' | 'table' | 'mixed' | 'chart'
|
output_format: 'text' | 'table' | 'mixed' | 'chart'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateBlock {
|
|
||||||
id: number
|
|
||||||
template_id?: number
|
|
||||||
source_paragraph_id?: number | null
|
|
||||||
parent_block_id?: number | null
|
|
||||||
sort_index: number
|
|
||||||
block_type: 'heading' | 'text' | 'table' | 'ai_slot' | 'variable'
|
|
||||||
anchor_ref: string
|
|
||||||
title: string
|
|
||||||
content_json: Record<string, any>
|
|
||||||
style_json: string
|
|
||||||
edit_mode: 'manual' | 'ai'
|
|
||||||
placeholder_key: string
|
|
||||||
variable_key: string
|
|
||||||
default_value: string
|
|
||||||
model_id: number | null
|
|
||||||
need_prompt: boolean
|
|
||||||
prompt_text: string
|
|
||||||
need_file: boolean
|
|
||||||
file_note: string
|
|
||||||
output_format: 'text' | 'table' | 'mixed' | 'chart'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiModel {
|
export interface AiModel {
|
||||||
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
|
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
|
||||||
api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled'
|
api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled'
|
||||||
|
|
|
||||||
|
|
@ -18,29 +18,29 @@
|
||||||
<div class="editor-layout">
|
<div class="editor-layout">
|
||||||
<aside class="editor-left">
|
<aside class="editor-left">
|
||||||
<div class="left-head">
|
<div class="left-head">
|
||||||
{{ editorMode === 'paragraph' ? '段落列表' : '内容块列表' }}
|
段落列表
|
||||||
<span class="left-head-tip">(点击定位)</span>
|
<span class="left-head-tip">(点击定位)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="left-scroll">
|
<div class="left-scroll">
|
||||||
<div
|
<div
|
||||||
v-for="item in currentItems"
|
v-for="paragraph in paragraphs"
|
||||||
:key="item.id"
|
:key="paragraph.id"
|
||||||
:class="['para-list-item', { active: selectedId === item.id }]"
|
:class="['para-list-item', { active: selectedId === paragraph.id }]"
|
||||||
@click="selectPara(item.id)"
|
@click="selectPara(paragraph.id)"
|
||||||
>
|
>
|
||||||
<span class="pli-index">{{ item.sort_index }}</span>
|
<span class="pli-index">{{ paragraph.sort_index }}</span>
|
||||||
<span class="pli-title">{{ listTitle(item) }}</span>
|
<span class="pli-title">{{ listTitle(paragraph) }}</span>
|
||||||
<span :class="['pli-badge', editorMode === 'manual' ? `block-${item.block_type || 'text'}` : (item.edit_mode === 'ai' ? 'ai' : 'manual')]">
|
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
|
||||||
{{ editorMode === 'manual' ? blockTypeLabel(item.block_type) : (item.edit_mode === 'ai' ? 'AI 生成' : '人工编辑') }}
|
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="pli-actions" @click.stop>
|
<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 />
|
<arrow-up-outlined />
|
||||||
</a-button>
|
</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 />
|
<arrow-down-outlined />
|
||||||
</a-button>
|
</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 />
|
<delete-outlined />
|
||||||
</a-button>
|
</a-button>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -66,17 +66,10 @@
|
||||||
<span class="toolbar-hint">
|
<span class="toolbar-hint">
|
||||||
{{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }}
|
{{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div class="center-scroll">
|
<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'">
|
<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)">
|
<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>
|
<span class="para-block-actions" @click.stop>
|
||||||
|
|
@ -100,52 +93,45 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="block in blocks"
|
v-for="paragraph in paragraphs"
|
||||||
:key="block.id"
|
:key="paragraph.id"
|
||||||
:class="['para-block', 'manual-block', blockCardClass(block), { selected: selectedId === block.id }]"
|
:class="['para-block', 'manual-block', { selected: selectedId === paragraph.id }]"
|
||||||
@click="selectPara(block.id)"
|
@click="selectPara(paragraph.id)"
|
||||||
>
|
>
|
||||||
<span :class="['para-tag', block.edit_mode === 'ai' ? 'ai' : 'manual']">
|
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
|
||||||
{{ blockTagLabel(block) }}
|
{{ paragraph.edit_mode === 'ai' ? 'AI 段落' : '手动段落' }}
|
||||||
</span>
|
</span>
|
||||||
<div class="manual-block-head">
|
<div class="manual-block-head">
|
||||||
<span class="manual-block-index">块 {{ block.sort_index }}</span>
|
<span class="manual-block-index">段落 {{ paragraph.sort_index }}</span>
|
||||||
<span class="manual-block-anchor" v-if="block.anchor_ref && block.anchor_ref !== block.title">
|
<span class="manual-block-anchor" v-if="paragraph.anchor_title && paragraph.anchor_title !== paragraph.title">
|
||||||
锚点:{{ block.anchor_ref }}
|
原标题:{{ paragraph.anchor_title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<div class="field-label">标题</div>
|
||||||
<a-input
|
<a-input
|
||||||
v-model:value="block.title"
|
v-model:value="paragraph.title"
|
||||||
class="manual-title-input"
|
class="manual-title-input"
|
||||||
:placeholder="block.block_type === 'heading' ? '输入标题文本' : '输入导出时使用的标题'"
|
placeholder="输入导出时使用的标题"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<div class="field-label">{{ block.block_type === 'variable' ? '变量默认值' : '正文' }}</div>
|
<div class="field-label">正文</div>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="block.content_json.text"
|
v-model:value="paragraph.content"
|
||||||
class="manual-content-input"
|
class="manual-content-input"
|
||||||
:rows="block.block_type === 'heading' ? 3 : 6"
|
:rows="paragraph.write_mode === 'replace_heading_only' ? 3 : 6"
|
||||||
:placeholder="contentPlaceholder(block)"
|
:placeholder="contentPlaceholder(paragraph)"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<div class="manual-block-actions">
|
<div class="manual-block-actions">
|
||||||
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'text')">后插正文块</a-button>
|
<a-button size="small" @click.stop="insertBlockAfter(paragraph, 'manual')">在后面新增固定块</a-button>
|
||||||
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'heading')">后插标题块</a-button>
|
<a-button size="small" type="primary" ghost @click.stop="insertBlockAfter(paragraph, 'ai')">在后面新增 AI 块</a-button>
|
||||||
<a-button size="small" type="primary" ghost @click.stop="insertSpecificBlockAfter(block, 'ai_slot')">后插 AI 块</a-button>
|
<a-button size="small" :disabled="!canMoveUp(paragraph)" @click.stop="moveUp(paragraph)">上移</a-button>
|
||||||
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'variable')">后插变量块</a-button>
|
<a-button size="small" :disabled="!canMoveDown(paragraph)" @click.stop="moveDown(paragraph)">下移</a-button>
|
||||||
<a-button size="small" :disabled="!canMoveUp(block)" @click.stop="moveUp(block)">上移</a-button>
|
<a-button size="small" danger :disabled="!canDeleteBlock(paragraph)" @click.stop="handleDeleteParagraph(paragraph)">删除当前块</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="manual-block-meta">
|
<div class="manual-block-meta">
|
||||||
<span class="meta-item">编辑方式:{{ block.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
|
<span class="meta-item">编辑方式:{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
|
||||||
<span class="meta-item">块类型:{{ blockTypeLabel(block.block_type) }}</span>
|
<span class="meta-item">写入方式:{{ writeModeLabel(paragraph.write_mode) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -156,41 +142,32 @@
|
||||||
<aside class="editor-right">
|
<aside class="editor-right">
|
||||||
<div class="right-head">
|
<div class="right-head">
|
||||||
<span>段落配置</span>
|
<span>段落配置</span>
|
||||||
<span class="right-head-name">{{ selectedConfigItem?.title || '' }}</span>
|
<span class="right-head-name">{{ selectedPara?.title || '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-scroll" v-if="selectedConfigItem">
|
<div class="right-scroll" v-if="selectedPara">
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-section-title">生成设置</div>
|
<div class="config-section-title">生成设置</div>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="编辑方式">
|
<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="ai">AI 生成</a-select-option>
|
||||||
<a-select-option value="manual">人工编辑</a-select-option>
|
<a-select-option value="manual">人工编辑</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="editorMode === 'paragraph'" label="写入方式">
|
<a-form-item label="写入方式">
|
||||||
<a-select v-model:value="selectedConfigItem.write_mode">
|
<a-select v-model:value="selectedPara.write_mode">
|
||||||
<a-select-option value="replace_section">替换标题下整段</a-select-option>
|
<a-select-option value="replace_section">替换标题下整段</a-select-option>
|
||||||
<a-select-option value="append_after_heading">标题下插入内容</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-option value="replace_heading_only">仅替换标题</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-else label="块类型">
|
<a-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
|
||||||
<a-select v-model:value="selectedConfigItem.block_type">
|
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型">
|
||||||
<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-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
|
<a-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="输出格式">
|
<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="text">正式报告段落</a-select-option>
|
||||||
<a-select-option value="table">表格形式</a-select-option>
|
<a-select-option value="table">表格形式</a-select-option>
|
||||||
<a-select-option value="mixed">混合内容</a-select-option>
|
<a-select-option value="mixed">混合内容</a-select-option>
|
||||||
|
|
@ -209,25 +186,19 @@
|
||||||
class="template-alert"
|
class="template-alert"
|
||||||
/>
|
/>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item :label="editorMode === 'paragraph' ? '原标题锚点' : '锚点引用'">
|
<a-form-item label="原标题锚点">
|
||||||
<a-input :value="currentAnchorValue(selectedConfigItem)" disabled />
|
<a-input :value="selectedPara.anchor_title || selectedPara.title" disabled />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="导出标题">
|
<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>
|
||||||
<a-form-item label="模板正文">
|
<a-form-item label="模板正文">
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="contentProxy"
|
v-model:value="selectedPara.content"
|
||||||
:rows="editorMode === 'paragraph' && selectedConfigItem.write_mode === 'replace_heading_only' ? 4 : 8"
|
:rows="selectedPara.write_mode === 'replace_heading_only' ? 4 : 8"
|
||||||
:placeholder="contentPlaceholder(selectedConfigItem)"
|
:placeholder="contentPlaceholder(selectedPara)"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</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>
|
</a-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -235,28 +206,28 @@
|
||||||
<div class="config-section-title">提示词与文件</div>
|
<div class="config-section-title">提示词与文件</div>
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<span>需要提示词</span>
|
<span>需要提示词</span>
|
||||||
<a-switch v-model:checked="selectedConfigItem.need_prompt" />
|
<a-switch v-model:checked="selectedPara.need_prompt" />
|
||||||
</div>
|
</div>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-if="selectedConfigItem.need_prompt"
|
v-if="selectedPara.need_prompt"
|
||||||
v-model:value="selectedConfigItem.prompt_text"
|
v-model:value="selectedPara.prompt_text"
|
||||||
:rows="4"
|
:rows="4"
|
||||||
placeholder="在此输入提示词..."
|
placeholder="在此输入提示词..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="toggle-row file-row">
|
<div class="toggle-row file-row">
|
||||||
<span>需要参考文件</span>
|
<span>需要参考文件</span>
|
||||||
<a-switch v-model:checked="selectedConfigItem.need_file" />
|
<a-switch v-model:checked="selectedPara.need_file" />
|
||||||
</div>
|
</div>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-if="selectedConfigItem.need_file"
|
v-if="selectedPara.need_file"
|
||||||
v-model:value="selectedConfigItem.file_note"
|
v-model:value="selectedPara.file_note"
|
||||||
:rows="3"
|
:rows="3"
|
||||||
placeholder="提示用户上传什么文件"
|
placeholder="提示用户上传什么文件"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -273,8 +244,8 @@
|
||||||
<div v-if="testStep === 0">
|
<div v-if="testStep === 0">
|
||||||
<ReferenceFileSelector
|
<ReferenceFileSelector
|
||||||
v-model="testSelectedFiles"
|
v-model="testSelectedFiles"
|
||||||
:title="selectedConfigItem?.title || ''"
|
:title="selectedPara?.title || ''"
|
||||||
:description="selectedConfigItem?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
|
:description="selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
|
||||||
variant="full"
|
variant="full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -309,7 +280,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
|
|
@ -336,7 +307,6 @@ const store = useTemplateStore()
|
||||||
const modelStore = useModelStore()
|
const modelStore = useModelStore()
|
||||||
|
|
||||||
const paragraphs = ref<any[]>([])
|
const paragraphs = ref<any[]>([])
|
||||||
const blocks = ref<any[]>([])
|
|
||||||
const models = ref<any[]>([])
|
const models = ref<any[]>([])
|
||||||
const selectedId = ref(0)
|
const selectedId = ref(0)
|
||||||
const templateName = ref('模板编辑')
|
const templateName = ref('模板编辑')
|
||||||
|
|
@ -353,48 +323,16 @@ const testFileSummaries = ref<any[]>([])
|
||||||
const streamedText = ref('')
|
const streamedText = ref('')
|
||||||
|
|
||||||
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
|
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) {
|
function selectPara(id: number) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeCollection() {
|
|
||||||
return editorMode.value === 'paragraph' ? paragraphs.value : blocks.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSortIndex() {
|
function normalizeSortIndex() {
|
||||||
const items = activeCollection()
|
paragraphs.value = paragraphs.value.map((item, index) => ({
|
||||||
const normalized = items.map((item, index) => ({
|
|
||||||
...item,
|
...item,
|
||||||
sort_index: index + 1,
|
sort_index: index + 1,
|
||||||
}))
|
}))
|
||||||
if (editorMode.value === 'paragraph') {
|
|
||||||
paragraphs.value = normalized
|
|
||||||
} else {
|
|
||||||
blocks.value = normalized
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeModeLabel(mode: string) {
|
function writeModeLabel(mode: string) {
|
||||||
|
|
@ -403,133 +341,43 @@ function writeModeLabel(mode: string) {
|
||||||
return '替换标题下整段'
|
return '替换标题下整段'
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentAnchorValue(item: any) {
|
function listTitle(paragraph: any) {
|
||||||
if (!item) return ''
|
if (!paragraph?.anchor_title || paragraph.anchor_title === paragraph.title) {
|
||||||
return editorMode.value === 'paragraph' ? item.anchor_title || item.title : item.anchor_ref || item.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})`
|
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title})`
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canDeleteBlock(_paragraph: any) {
|
function canDeleteBlock(_paragraph: any) {
|
||||||
return activeCollection().length > 1
|
return paragraphs.value.length > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canMoveUp(paragraph: any) {
|
function canMoveUp(paragraph: any) {
|
||||||
const index = activeCollection().findIndex((item) => item === paragraph)
|
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||||
return index > 0
|
return index > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function canMoveDown(paragraph: any) {
|
function canMoveDown(paragraph: any) {
|
||||||
const items = activeCollection()
|
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||||
const index = items.findIndex((item) => item === paragraph)
|
return index >= 0 && index < paragraphs.value.length - 1
|
||||||
return index >= 0 && index < items.length - 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveUp(paragraph: any) {
|
function moveUp(paragraph: any) {
|
||||||
const items = activeCollection()
|
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||||
const index = items.findIndex((item) => item === paragraph)
|
|
||||||
if (index <= 0) return
|
if (index <= 0) return
|
||||||
const temp = items[index]
|
const temp = paragraphs.value[index]
|
||||||
items[index] = items[index - 1]
|
paragraphs.value[index] = paragraphs.value[index - 1]
|
||||||
items[index - 1] = temp
|
paragraphs.value[index - 1] = temp
|
||||||
normalizeSortIndex()
|
normalizeSortIndex()
|
||||||
autoSaveParagraphs()
|
autoSaveParagraphs()
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveDown(paragraph: any) {
|
function moveDown(paragraph: any) {
|
||||||
const items = activeCollection()
|
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||||
const index = items.findIndex((item) => item === paragraph)
|
if (index < 0 || index >= paragraphs.value.length - 1) return
|
||||||
if (index < 0 || index >= items.length - 1) return
|
const temp = paragraphs.value[index]
|
||||||
const temp = items[index]
|
paragraphs.value[index] = paragraphs.value[index + 1]
|
||||||
items[index] = items[index + 1]
|
paragraphs.value[index + 1] = temp
|
||||||
items[index + 1] = temp
|
|
||||||
normalizeSortIndex()
|
normalizeSortIndex()
|
||||||
autoSaveParagraphs()
|
autoSaveParagraphs()
|
||||||
}
|
}
|
||||||
|
|
@ -568,37 +416,10 @@ async function autoSaveParagraphs() {
|
||||||
file_note: p.file_note,
|
file_note: p.file_note,
|
||||||
output_format: p.output_format,
|
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 {
|
try {
|
||||||
console.log('[autoSave] sending paragraphs:', data.map(p => ({ id: p.id, title: p.title })))
|
console.log('[autoSave] sending paragraphs:', data.map(p => ({ id: p.id, title: p.title })))
|
||||||
const response: any = await templateApi.saveParagraphs(templateId, {
|
await templateApi.saveParagraphs(templateId, { paragraphs: data })
|
||||||
save_mode: editorMode.value,
|
|
||||||
paragraphs: data,
|
|
||||||
blocks: editorMode.value === 'manual' ? blockData : [],
|
|
||||||
})
|
|
||||||
store.paragraphs = paragraphs.value as any
|
store.paragraphs = paragraphs.value as any
|
||||||
store.blocks = response.data?.blocks || blocks.value
|
|
||||||
blocks.value = store.blocks as any
|
|
||||||
console.log('[autoSave] save success')
|
console.log('[autoSave] save success')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[autoSave] save failed:', e)
|
console.error('[autoSave] save failed:', e)
|
||||||
|
|
@ -607,7 +428,6 @@ async function autoSaveParagraphs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
|
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
|
||||||
if (editorMode.value === 'paragraph') {
|
|
||||||
const index = paragraphs.value.findIndex((item) => item === sourceParagraph)
|
const index = paragraphs.value.findIndex((item) => item === sourceParagraph)
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_title || '未命名段落'
|
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_title || '未命名段落'
|
||||||
|
|
@ -635,38 +455,6 @@ function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
selectedId.value = newBlock.id
|
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) {
|
function removeBlock(paragraph: any) {
|
||||||
|
|
@ -674,22 +462,16 @@ function removeBlock(paragraph: any) {
|
||||||
message.warning('至少保留一个段落')
|
message.warning('至少保留一个段落')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const items = activeCollection()
|
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||||
const index = items.findIndex((item) => item === paragraph)
|
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
items.splice(index, 1)
|
paragraphs.value.splice(index, 1)
|
||||||
normalizeSortIndex()
|
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
|
selectedId.value = next?.id || 0
|
||||||
autoSaveParagraphs()
|
autoSaveParagraphs()
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentPlaceholder(paragraph: any) {
|
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') {
|
if (paragraph?.edit_mode === 'manual') {
|
||||||
return paragraph?.write_mode === 'replace_heading_only'
|
return paragraph?.write_mode === 'replace_heading_only'
|
||||||
? '仅替换标题时,这里的正文仅作为备注保留,不会覆盖原文。'
|
? '仅替换标题时,这里的正文仅作为备注保留,不会覆盖原文。'
|
||||||
|
|
@ -704,13 +486,13 @@ function contentPlaceholder(paragraph: any) {
|
||||||
async function saveTemplate() {
|
async function saveTemplate() {
|
||||||
const templateId = Number(route.params.id)
|
const templateId = Number(route.params.id)
|
||||||
store.paragraphs = paragraphs.value as any
|
store.paragraphs = paragraphs.value as any
|
||||||
store.blocks = blocks.value as any
|
|
||||||
await store.save(templateId)
|
await store.save(templateId)
|
||||||
const template = await store.fetchOne(templateId)
|
const template = await store.fetchOne(templateId)
|
||||||
templateName.value = template.name
|
templateName.value = template.name
|
||||||
paragraphs.value = store.paragraphs as any
|
paragraphs.value = store.paragraphs as any
|
||||||
blocks.value = store.blocks as any
|
if (selectedId.value <= 0 && paragraphs.value.length) {
|
||||||
syncSelectionForMode()
|
selectedId.value = paragraphs.value[0].id
|
||||||
|
}
|
||||||
message.success('模板配置已保存')
|
message.success('模板配置已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -760,13 +542,8 @@ function renderTestResult(content: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTest() {
|
async function startTest() {
|
||||||
if (!selectedConfigItem.value) return
|
if (!selectedPara.value) return
|
||||||
const paragraphId = editorMode.value === 'paragraph' ? selectedPara.value?.id : selectedBlock.value?.source_paragraph_id
|
if (selectedPara.value.need_file && !testSelectedFiles.value.length) {
|
||||||
if (!paragraphId) {
|
|
||||||
message.warning('当前块还没有绑定可测试的原始段落,请先保存模板后再测试')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (selectedConfigItem.value.need_file && !testSelectedFiles.value.length) {
|
|
||||||
message.warning('请先上传至少一个参考文件')
|
message.warning('请先上传至少一个参考文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -780,17 +557,17 @@ async function startTest() {
|
||||||
|
|
||||||
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
|
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
|
||||||
const templateId = Number(route.params.id)
|
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) {
|
if (currentModel?.supports_streaming) {
|
||||||
testStatusText.value = '正在流式接收模型返回内容...'
|
testStatusText.value = '正在流式接收模型返回内容...'
|
||||||
streamedText.value = ''
|
streamedText.value = ''
|
||||||
await runStreamingTest(templateId, filePaths, paragraphId)
|
await runStreamingTest(templateId, filePaths)
|
||||||
} else {
|
} else {
|
||||||
const response: any = await generateApi.test({
|
const response: any = await generateApi.test({
|
||||||
paragraph_id: paragraphId,
|
paragraph_id: selectedPara.value.id,
|
||||||
template_id: templateId,
|
template_id: templateId,
|
||||||
prompt_text: selectedConfigItem.value.prompt_text || '',
|
prompt_text: selectedPara.value.prompt_text || '',
|
||||||
model_id: selectedConfigItem.value.model_id || 0,
|
model_id: selectedPara.value.model_id || 0,
|
||||||
file_paths: filePaths,
|
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(), {
|
const response = await fetch(generateApi.testStream(), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
paragraph_id: paragraphId,
|
paragraph_id: selectedPara.value.id,
|
||||||
template_id: templateId,
|
template_id: templateId,
|
||||||
prompt_text: selectedConfigItem.value.prompt_text || '',
|
prompt_text: selectedPara.value.prompt_text || '',
|
||||||
model_id: selectedConfigItem.value.model_id || 0,
|
model_id: selectedPara.value.model_id || 0,
|
||||||
file_paths: filePaths,
|
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 () => {
|
onMounted(async () => {
|
||||||
const id = Number(route.params.id)
|
const id = Number(route.params.id)
|
||||||
const template = await store.fetchOne(id)
|
const template = await store.fetchOne(id)
|
||||||
templateName.value = template.name
|
templateName.value = template.name
|
||||||
paragraphs.value = store.paragraphs as any
|
paragraphs.value = store.paragraphs as any
|
||||||
blocks.value = store.blocks as any
|
|
||||||
await modelStore.fetchList()
|
await modelStore.fetchList()
|
||||||
models.value = modelStore.models as any
|
models.value = modelStore.models as any
|
||||||
syncSelectionForMode()
|
if (paragraphs.value.length) {
|
||||||
|
selectedId.value = paragraphs.value[0].id
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -1036,12 +799,6 @@ onMounted(async () => {
|
||||||
flex-shrink: 0;
|
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 {
|
.para-block {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
@ -1111,31 +868,6 @@ onMounted(async () => {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
border: 1px solid #e7e9ee;
|
border: 1px solid #e7e9ee;
|
||||||
border-radius: 12px;
|
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 {
|
.manual-block-head {
|
||||||
|
|
@ -1157,31 +889,6 @@ onMounted(async () => {
|
||||||
color: #7b8190;
|
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 {
|
.field-label {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -1317,31 +1024,6 @@ onMounted(async () => {
|
||||||
color: #e68a00;
|
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 {
|
.pli-actions {
|
||||||
display: none;
|
display: none;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue