Compare commits

...

16 Commits

Author SHA1 Message Date
zwt13703 28f87fbb62 fix: commit template upload atomically 2026-07-01 21:50:38 +08:00
zwt13703 1ff0fdf602 feat: add template center list 2026-07-01 21:50:07 +08:00
zwt13703 5948334d15 feat: add template upload UI flow 2026-07-01 21:49:06 +08:00
zwt13703 5a24edc3b0 feat: connect template editor to APIs 2026-07-01 21:48:11 +08:00
zwt13703 60bdd5f268 feat: wire template stores and linkage 2026-07-01 20:48:02 +08:00
zwt13703 04b5536698 feat: complete config panel form 2026-07-01 20:44:06 +08:00
zwt13703 7a1e069139 feat: add recursive structure tree 2026-07-01 20:41:48 +08:00
zwt13703 5da6f7f3d0 feat: add interactive word preview 2026-07-01 20:39:38 +08:00
zwt13703 65e10064ea feat: enhance top toolbar state 2026-07-01 20:37:42 +08:00
zwt13703 ffa869e086 feat: add interactive left menu 2026-07-01 20:36:04 +08:00
zwt13703 b4e128c227 feat: add template edit shell layout 2026-07-01 20:34:24 +08:00
zwt13703 217af9bc99 feat: add template detail and config APIs 2026-07-01 20:31:54 +08:00
zwt13703 611e089c9e feat: generate doc preview html 2026-07-01 20:30:34 +08:00
zwt13703 389b9c0c92 feat: parse docx blocks during upload 2026-07-01 20:29:31 +08:00
zwt13703 95c507536a feat: add template upload endpoint 2026-07-01 20:27:15 +08:00
zwt13703 beec8cf5bb chore: bootstrap storage migrations and frontend 2026-07-01 20:25:48 +08:00
43 changed files with 5192 additions and 155 deletions
+7
View File
@@ -2,3 +2,10 @@
.idea .idea
.idea/** .idea/**
node_modules node_modules
dist
frontend/dist
__pycache__
*.pyc
*.tsbuildinfo
frontend/vite.config.js
frontend/vite.config.d.ts
+40
View File
@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = mysql+pymysql://root:root123@localhost:3306/ai_doc_template?charset=utf8mb4
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+62
View File
@@ -0,0 +1,62 @@
from logging.config import fileConfig
import os
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.database import Base
from app.models.block_config import BlockConfig
from app.models.template import Template
from app.models.template_block import TemplateBlock
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_database_url() -> str:
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "3306")
db_user = os.getenv("DB_USER", "root")
db_pass = os.getenv("DB_PASS", "root123")
db_name = os.getenv("DB_NAME", "ai_doc_template")
return f"mysql+pymysql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}?charset=utf8mb4"
config.set_main_option("sqlalchemy.url", get_database_url())
def run_migrations_offline() -> None:
context.configure(
url=get_database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,74 @@
"""create template tables
Revision ID: 20260701_0001
Revises:
Create Date: 2026-07-01 12:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260701_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"template",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=200), nullable=False, comment="模板名称"),
sa.Column("type", sa.String(length=50), nullable=True, comment="报告类/公文类/总结类/合同类"),
sa.Column("version", sa.String(length=20), nullable=True, comment="版本号"),
sa.Column("original_file_path", sa.String(length=500), nullable=False, comment="原始 Word 文件路径"),
sa.Column("status", sa.Integer(), nullable=True, comment="1=启用 0=停用"),
sa.Column("created_by", sa.String(length=50), nullable=True, comment="创建人"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"template_block",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("template_id", sa.Integer(), nullable=False, comment="所属模板"),
sa.Column("block_id", sa.String(length=50), nullable=False, comment="唯一标识 block_001"),
sa.Column("parent_block_id", sa.String(length=50), nullable=True, comment="父级 block_id"),
sa.Column("block_type", sa.String(length=20), nullable=False, comment="title/heading/paragraph/table"),
sa.Column("block_name", sa.String(length=200), nullable=True, comment="区域名称"),
sa.Column("text_preview", sa.String(length=500), nullable=True, comment="文本预览"),
sa.Column("level", sa.Integer(), nullable=True, comment="0=文档标题 1=一级 2=二级"),
sa.Column("sort_order", sa.Integer(), nullable=True, comment="排序号"),
sa.Column("table_rows", sa.Integer(), nullable=True, comment="表格行数"),
sa.Column("table_cols", sa.Integer(), nullable=True, comment="表格列数"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["template_id"], ["template.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"block_config",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("template_id", sa.Integer(), nullable=False),
sa.Column("block_id", sa.String(length=50), nullable=False),
sa.Column("region_name", sa.String(length=200), nullable=True, comment="区域名称"),
sa.Column("region_type", sa.String(length=30), nullable=True, comment="ai_generate/manual/fixed/table"),
sa.Column("data_sources", sa.Text(), nullable=True, comment="数据来源 JSON 数组"),
sa.Column("prompt", sa.Text(), nullable=True, comment="提示词"),
sa.Column("output_format", sa.String(length=30), nullable=True, comment="输出格式"),
sa.Column("need_review", sa.Integer(), nullable=True, comment="1=需要审核"),
sa.Column("remark", sa.Text(), nullable=True, comment="备注"),
sa.Column("enabled", sa.Integer(), nullable=True, comment="1=启用"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["template_id"], ["template.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("template_id", "block_id", name="uq_template_block"),
)
def downgrade() -> None:
op.drop_table("block_config")
op.drop_table("template_block")
op.drop_table("template")
+27
View File
@@ -0,0 +1,27 @@
from fastapi import APIRouter, HTTPException
from ..schemas.template import DataSourcePayload
router = APIRouter(prefix="/api/data-sources", tags=["data-sources"])
DATA_SOURCES: list[dict] = [
{"code": "policy", "name": "政策文件", "description": "政策法规、制度文件等资料"},
{"code": "business_data", "name": "业务数据", "description": "业务系统导出的结构化数据"},
{"code": "research", "name": "调研材料", "description": "访谈、问卷、调研纪要等非结构化资料"},
]
@router.get("")
def list_data_sources():
return {"data": DATA_SOURCES, "message": "ok"}
@router.post("")
def create_data_source(payload: DataSourcePayload):
if any(item["code"] == payload.code for item in DATA_SOURCES):
raise HTTPException(status_code=400, detail="数据源编码已存在")
item = payload.model_dump()
DATA_SOURCES.append(item)
return {"data": item, "message": "ok"}
+171
View File
@@ -0,0 +1,171 @@
import json
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.block_config import BlockConfig
from ..models.template import Template
from ..models.template_block import TemplateBlock
from ..schemas.template import BlockConfigPayload
from ..services.doc_parser import build_tree
from ..services.doc_parser import parse_document
from ..services.html_generator import generate_preview_html
from ..services.template_service import create_template_record, save_template_blocks, store_template_file
router = APIRouter(prefix="/api/templates", tags=["templates"])
@router.get("")
def list_templates(db: Session = Depends(get_db)):
templates = db.query(Template).order_by(Template.updated_at.desc(), Template.id.desc()).all()
return {"data": [serialize_template(template) for template in templates], "message": "ok"}
@router.post("/upload")
def upload_template(
file: UploadFile = File(...),
name: str = Form(...),
type: str = Form("report"),
db: Session = Depends(get_db),
):
"""上传 Word 模板并创建模板记录。"""
try:
stored_path, content = store_template_file(file)
blocks = parse_document(content)
template = create_template_record(db, name=name, file_path=stored_path, type=type, commit=False)
save_template_blocks(db, template.id, blocks, commit=False)
db.commit()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
db.rollback()
raise HTTPException(status_code=500, detail="模板上传失败") from exc
return {
"data": {
"template_id": template.id,
"name": template.name,
"block_count": len(blocks),
"status": "uploaded",
},
"message": "ok",
}
@router.get("/{template_id}")
def get_template_detail(template_id: int, db: Session = Depends(get_db)):
template = db.get(Template, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
block_records = (
db.query(TemplateBlock)
.filter(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_order.asc(), TemplateBlock.id.asc())
.all()
)
blocks = [serialize_block(block) for block in block_records]
tree = build_tree([block.copy() for block in blocks])
configs = {
config.block_id: serialize_config(config)
for config in db.query(BlockConfig).filter(BlockConfig.template_id == template_id).all()
}
return {
"data": {
"template": serialize_template(template),
"preview_html": generate_preview_html(blocks),
"blocks": blocks,
"tree": tree,
"configs": configs,
},
"message": "ok",
}
@router.post("/{template_id}/blocks/{block_id}/config")
def save_block_config(
template_id: int,
block_id: str,
payload: BlockConfigPayload,
db: Session = Depends(get_db),
):
block = (
db.query(TemplateBlock)
.filter(TemplateBlock.template_id == template_id, TemplateBlock.block_id == block_id)
.first()
)
if not block:
raise HTTPException(status_code=404, detail="模板区域不存在")
config = (
db.query(BlockConfig)
.filter(BlockConfig.template_id == template_id, BlockConfig.block_id == block_id)
.first()
)
if not config:
config = BlockConfig(template_id=template_id, block_id=block_id)
db.add(config)
config.region_name = payload.region_name
config.region_type = payload.region_type
config.data_sources = json.dumps(payload.data_sources, ensure_ascii=False)
config.prompt = payload.prompt
config.output_format = payload.output_format
config.need_review = payload.need_review
config.remark = payload.remark
config.enabled = payload.enabled
db.commit()
db.refresh(config)
return {"data": serialize_config(config), "message": "ok"}
def serialize_template(template: Template) -> dict:
return {
"id": template.id,
"name": template.name,
"type": template.type,
"version": template.version,
"original_file_path": template.original_file_path,
"status": template.status,
"created_by": template.created_by,
"created_at": template.created_at.isoformat() if template.created_at else None,
"updated_at": template.updated_at.isoformat() if template.updated_at else None,
}
def serialize_block(block: TemplateBlock) -> dict:
return {
"id": block.id,
"template_id": block.template_id,
"block_id": block.block_id,
"parent_block_id": block.parent_block_id,
"type": block.block_type,
"block_type": block.block_type,
"block_name": block.block_name,
"text": block.text_preview or "",
"text_preview": block.text_preview,
"level": block.level,
"sort_order": block.sort_order,
"table_rows": block.table_rows,
"table_cols": block.table_cols,
}
def serialize_config(config: BlockConfig) -> dict:
return {
"id": config.id,
"template_id": config.template_id,
"block_id": config.block_id,
"region_name": config.region_name,
"region_type": config.region_type,
"data_sources": json.loads(config.data_sources or "[]"),
"prompt": config.prompt,
"output_format": config.output_format,
"need_review": config.need_review,
"remark": config.remark,
"enabled": config.enabled,
}
+6
View File
@@ -1,6 +1,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .api.data_sources import router as data_sources_router
from .api.templates import router as templates_router
app = FastAPI(title="AI 文档模板生成系统", version="0.1.0") app = FastAPI(title="AI 文档模板生成系统", version="0.1.0")
app.add_middleware( app.add_middleware(
@@ -11,6 +14,9 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(templates_router)
app.include_router(data_sources_router)
@app.get("/health") @app.get("/health")
def health(): def health():
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
class BlockConfigPayload(BaseModel):
region_name: str | None = None
region_type: str = "ai_generate"
data_sources: list[str] = Field(default_factory=list)
prompt: str | None = None
output_format: str = "formal_paragraph"
need_review: int = 1
remark: str | None = None
enabled: int = 1
class DataSourcePayload(BaseModel):
name: str
code: str
description: str | None = None
+98 -45
View File
@@ -1,56 +1,92 @@
"""Word 文档解析引擎""" """Word 文档解析引擎"""
from io import BytesIO
from typing import Any, Dict, Iterable, List
from docx import Document from docx import Document
from docx.paragraph import Paragraph from docx.document import Document as DocxDocument
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table as DocxTable from docx.table import Table as DocxTable
from typing import List, Dict, Any from docx.table import _Cell
from docx.text.paragraph import Paragraph
def parse_document(file_path: str) -> List[Dict[str, Any]]: def parse_document(source: str | bytes) -> List[Dict[str, Any]]:
""" """
解析 Word 文档,返回 block 列表(不含表格数据,仅标记位置) 解析 Word 文档,返回带 block_id、parent_block_id、sort_order 的 block 列表。
每个 block 包含:type, text, level, style
""" """
doc = Document(file_path) doc = Document(BytesIO(source)) if isinstance(source, bytes) else Document(source)
blocks = [] blocks: List[Dict[str, Any]] = []
for item in iter_block_items(doc):
if isinstance(item, Paragraph):
block = classify_paragraph(item)
else:
block = classify_table(item)
for para in doc.paragraphs:
block = classify_paragraph(para)
if block: if block:
blocks.append(block) blocks.append(block)
assign_block_ids(blocks)
build_tree(blocks)
return blocks return blocks
def iter_block_items(parent: DocxDocument | _Cell) -> Iterable[Paragraph | DocxTable]:
"""按 Word 文档中的实际顺序遍历段落和表格。"""
parent_element = parent.element.body if isinstance(parent, DocxDocument) else parent._tc
for child in parent_element.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield DocxTable(child, parent)
def classify_paragraph(para: Paragraph) -> Dict[str, Any] | None: def classify_paragraph(para: Paragraph) -> Dict[str, Any] | None:
"""识别段落类型""" """识别段落类型"""
text = para.text.strip() text = para.text.strip()
style_name = para.style.name if para.style else "" style_name = para.style.name if para.style else ""
if not text and "Heading" not in style_name: if not text:
return None # 跳过空段落 return None # 跳过空段落
if "Heading" in style_name: if is_heading_style(style_name):
level = int(style_name.replace("Heading", "").strip()) if style_name.replace("Heading", "").strip().isdigit() else 1 level = parse_heading_level(style_name)
return {"type": "heading", "text": text, "level": level, "style": style_name} return {"type": "heading", "text": text, "level": level, "style": style_name}
else:
return {"type": "paragraph", "text": text, "level": 0, "style": style_name} return {"type": "paragraph", "text": text, "level": 0, "style": style_name}
def parse_tables(doc: Document) -> List[Dict[str, Any]]: def is_heading_style(style_name: str) -> bool:
"""解析 Word 表格""" return style_name.startswith("Heading") or style_name.startswith("标题")
tables = []
for i, table in enumerate(doc.tables):
headers = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else [] def parse_heading_level(style_name: str) -> int:
tables.append({ parts = style_name.replace("Heading", "").replace("标题", "").strip()
"type": "table", return int(parts) if parts.isdigit() and 1 <= int(parts) <= 6 else 1
"text": " | ".join(headers),
"level": 0,
"rows": len(table.rows), def classify_table(table: DocxTable) -> Dict[str, Any] | None:
"cols": len(table.columns), """识别表格类型并提取行列数。"""
"headers": headers, rows = len(table.rows)
"table_index": i, cols = len(table.columns)
}) if rows == 0 or cols == 0:
return tables return None
first_row = [cell.text.strip() for cell in table.rows[0].cells]
text = " | ".join(cell for cell in first_row if cell)
if not text:
text = f"表格({rows} 行 x {cols} 列)"
return {
"type": "table",
"text": text,
"level": 0,
"rows": rows,
"cols": cols,
"headers": first_row,
}
def generate_block_id(index: int) -> str: def generate_block_id(index: int) -> str:
@@ -58,29 +94,46 @@ def generate_block_id(index: int) -> str:
return f"block_{index:03d}" return f"block_{index:03d}"
def build_tree(blocks: List[Dict]) -> Dict: def assign_block_ids(blocks: List[Dict[str, Any]]) -> None:
for index, block in enumerate(blocks, start=1):
block["block_id"] = generate_block_id(index)
block["sort_order"] = index
block["parent_block_id"] = None
def build_tree(blocks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""构建层级树结构""" """构建层级树结构"""
tree = [] tree: List[Dict[str, Any]] = []
stack = [] # 存父级节点 heading_stack: List[Dict[str, Any]] = []
for block in blocks: for block in blocks:
node = {"block_id": block["block_id"], "text": block["text"][:50], "children": []} node = {
level = block.get("level", 0) "block_id": block["block_id"],
"block_type": block["type"],
"text": block["text"][:50],
"level": block.get("level", 0),
"children": [],
}
# 回退栈 if block["type"] == "heading":
while stack and stack[-1]["level"] >= level: level = block.get("level", 1)
stack.pop()
if stack: while heading_stack and heading_stack[-1]["level"] >= level:
parent = stack[-1]["node"] heading_stack.pop()
if heading_stack:
parent = heading_stack[-1]["node"]
parent["children"].append(node)
block["parent_block_id"] = parent["block_id"]
else:
tree.append(node)
heading_stack.append({"level": level, "node": node})
elif heading_stack:
parent = heading_stack[-1]["node"]
parent["children"].append(node) parent["children"].append(node)
block["parent_block_id"] = parent["block_id"] block["parent_block_id"] = parent["block_id"]
else: else:
tree.append(node) tree.append(node)
block["parent_block_id"] = None
# 非叶子节点入栈 return tree
if level < 2 or block["type"] == "heading":
stack.append({"level": level, "node": node})
return {"blocks": tree}
+62 -7
View File
@@ -1,5 +1,25 @@
"""HTML 预览生成服务""" """HTML 预览生成服务"""
from typing import List, Dict, Any from html import escape
from typing import Any, Dict, List
PAGE_STYLE = (
"width:794px;min-height:1123px;margin:0 auto;background:#fff;"
"padding:72px 64px;box-sizing:border-box;color:#1a1d24;"
"font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;"
"font-size:14px;line-height:1.8;"
)
HEADING_STYLE = (
"font-size:16px;font-weight:700;text-align:center;margin:24px 0 14px;"
"padding-bottom:6px;border-bottom:2px solid #1a1d24;"
)
PARAGRAPH_STYLE = "text-indent:2em;margin:8px 0;line-height:1.8;text-align:justify;"
TABLE_STYLE = "width:100%;border-collapse:collapse;margin:14px 0;font-size:13px;"
TH_STYLE = "padding:7px 10px;border:1px solid #d8dbe2;background:#f0f1f3;text-align:left;font-weight:600;"
TD_STYLE = "padding:7px 10px;border:1px solid #d8dbe2;text-align:left;min-height:24px;"
def generate_preview_html(blocks: List[Dict[str, Any]]) -> str: def generate_preview_html(blocks: List[Dict[str, Any]]) -> str:
@@ -7,21 +27,56 @@ def generate_preview_html(blocks: List[Dict[str, Any]]) -> str:
将 block 列表转为带 data-block-id 的 HTML 预览字符串。 将 block 列表转为带 data-block-id 的 HTML 预览字符串。
每个元素都会带上 data-block-id 属性供前端点击交互。 每个元素都会带上 data-block-id 属性供前端点击交互。
""" """
html_parts = ['<div class="doc-preview-content">'] html_parts = [f'<div class="doc-preview-content" style="{PAGE_STYLE}">']
for block in blocks: for block in blocks:
block_id = block.get("block_id", "") block_id = block.get("block_id", "")
block_type = block.get("type", "") block_type = block.get("type") or block.get("block_type", "")
text = block.get("text", "") text = block.get("text") or block.get("text_preview") or ""
if block_type == "heading": if block_type == "heading":
html = f'<h2 data-block-id="{block_id}" style="font-size:16px;font-weight:600;margin:20px 0 10px;padding-bottom:6px;border-bottom:2px solid #1a1d24">{text}</h2>' html = render_heading(block_id, text)
elif block_type == "table": elif block_type == "table":
html = f'<div data-block-id="{block_id}" style="margin:12px 0;padding:8px;background:#f7f8fa;border:1px dashed #ccc;border-radius:4px;color:#5b626e">📊 {text[:80]}</div>' html = render_table(block)
else: else:
html = f'<p data-block-id="{block_id}" style="text-indent:2em;margin:8px 0;line-height:1.8;text-align:justify">{text}</p>' html = render_paragraph(block_id, text)
html_parts.append(html) html_parts.append(html)
html_parts.append('</div>') html_parts.append('</div>')
return "\n".join(html_parts) return "\n".join(html_parts)
def render_heading(block_id: str, text: str) -> str:
return f'<h2 data-block-id="{escape(block_id)}" style="{HEADING_STYLE}">{escape(text)}</h2>'
def render_paragraph(block_id: str, text: str) -> str:
return f'<p data-block-id="{escape(block_id)}" style="{PARAGRAPH_STYLE}">{escape(text)}</p>'
def render_table(block: Dict[str, Any]) -> str:
block_id = escape(block.get("block_id", ""))
headers = [escape(str(header)) for header in block.get("headers", []) if str(header).strip()]
rows = int(block.get("rows") or block.get("table_rows") or 1)
cols = int(block.get("cols") or block.get("table_cols") or max(len(headers), 1))
cols = max(cols, len(headers), 1)
body_rows = max(rows - 1, 1)
if not headers:
text = block.get("text") or block.get("text_preview") or "表格"
headers = [escape(str(text))] + ["" for _ in range(cols - 1)]
header_html = "".join(f'<th style="{TH_STYLE}">{header}</th>' for header in headers[:cols])
if len(headers) < cols:
header_html += "".join(f'<th style="{TH_STYLE}"></th>' for _ in range(cols - len(headers)))
row_html = "".join(f'<td style="{TD_STYLE}"></td>' for _ in range(cols))
body_html = "\n".join(f"<tr>{row_html}</tr>" for _ in range(body_rows))
return (
f'<table data-block-id="{block_id}" style="{TABLE_STYLE}">'
f"<thead><tr>{header_html}</tr></thead>"
f"<tbody>{body_html}</tbody>"
"</table>"
)
+83 -10
View File
@@ -1,28 +1,101 @@
import os import os
from io import BytesIO from io import BytesIO
from typing import Any
# 先实现本地文件存储,MinIO 作为选项
STORAGE_BASE = os.getenv("LOCAL_STORAGE_PATH", "./data/files") STORAGE_BASE = os.getenv("LOCAL_STORAGE_PATH", "./data/files")
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
def save_file(content: bytes, file_path: str) -> str: def _local_path(bucket: str, file_path: str) -> str:
"""保存文件到本地存储,返回完整路径""" return os.path.join(STORAGE_BASE, bucket, file_path)
full_path = os.path.join(STORAGE_BASE, file_path)
def _save_local(bucket: str, file_path: str, content: bytes) -> str:
full_path = _local_path(bucket, file_path)
os.makedirs(os.path.dirname(full_path), exist_ok=True) os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f: with open(full_path, "wb") as f:
f.write(content) f.write(content)
return full_path return full_path
def read_file(file_path: str) -> bytes: def _read_local(bucket: str, file_path: str) -> bytes:
"""读取文件内容""" with open(_local_path(bucket, file_path), "rb") as f:
full_path = os.path.join(STORAGE_BASE, file_path)
with open(full_path, "rb") as f:
return f.read() return f.read()
def get_minio_client() -> Any:
from minio import Minio
return Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=MINIO_SECURE,
)
def ensure_bucket(bucket_name: str) -> bool:
"""确保 bucket 存在;MinIO 不可用时返回 False 表示使用本地降级。"""
try:
client = get_minio_client()
if not client.bucket_exists(bucket_name):
client.make_bucket(bucket_name)
return True
except Exception:
return False
def upload_file(bucket: str, file_path: str, content: bytes) -> str:
"""上传文件到 MinIO;连接失败时降级保存到本地文件系统。"""
if ensure_bucket(bucket):
try:
client = get_minio_client()
client.put_object(
bucket,
file_path,
BytesIO(content),
length=len(content),
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return f"minio://{bucket}/{file_path}"
except Exception:
pass
return _save_local(bucket, file_path, content)
def download_file(bucket: str, file_path: str) -> bytes:
"""从 MinIO 下载文件;读取失败时尝试本地降级路径。"""
if ensure_bucket(bucket):
try:
client = get_minio_client()
response = client.get_object(bucket, file_path)
try:
return response.read()
finally:
response.close()
response.release_conn()
except Exception:
pass
return _read_local(bucket, file_path)
def save_file(content: bytes, file_path: str) -> str:
"""兼容旧调用:保存到默认 templates bucket。"""
return upload_file("templates", file_path, content)
def read_file(file_path: str) -> bytes:
"""兼容旧调用:从默认 templates bucket 读取。"""
return download_file("templates", file_path)
def delete_file(file_path: str): def delete_file(file_path: str):
"""删除文件""" """删除本地降级文件。MinIO 对象删除后续按接口需要再补充。"""
full_path = os.path.join(STORAGE_BASE, file_path) full_path = _local_path("templates", file_path)
if os.path.exists(full_path): if os.path.exists(full_path):
os.remove(full_path) os.remove(full_path)
+67 -2
View File
@@ -1,15 +1,25 @@
"""模板相关业务逻辑""" """模板相关业务逻辑"""
from datetime import datetime
from pathlib import Path
from uuid import uuid4
from fastapi import UploadFile from fastapi import UploadFile
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models.template import Template from ..models.template import Template
from ..models.template_block import TemplateBlock
from .storage import upload_file
ALLOWED_EXTENSIONS = {".docx"} ALLOWED_EXTENSIONS = {".docx"}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
TEMPLATE_BUCKET = "templates"
def validate_file(file: UploadFile): def validate_file(file: UploadFile):
"""校验文件格式和大小""" """校验文件格式和大小"""
if not file.filename:
raise ValueError("文件名不能为空")
ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_EXTENSIONS: if ext not in ALLOWED_EXTENSIONS:
raise ValueError(f"不支持的文件格式: {ext},仅支持 .docx") raise ValueError(f"不支持的文件格式: {ext},仅支持 .docx")
@@ -21,10 +31,65 @@ def validate_file(file: UploadFile):
return content return content
def create_template_record(db: Session, name: str, file_path: str, type: str = "report") -> Template: def build_template_file_path(filename: str) -> str:
"""生成模板文件存储路径。"""
suffix = Path(filename).suffix.lower()
today = datetime.now().strftime("%Y%m%d")
return f"{today}/{uuid4().hex}{suffix}"
def store_template_file(file: UploadFile) -> tuple[str, bytes]:
"""校验并保存上传的模板文件,返回存储路径和文件内容。"""
content = validate_file(file)
file_path = build_template_file_path(file.filename or "template.docx")
stored_path = upload_file(TEMPLATE_BUCKET, file_path, content)
return stored_path, content
def create_template_record(
db: Session,
name: str,
file_path: str,
type: str = "report",
commit: bool = True,
) -> Template:
"""创建模板记录""" """创建模板记录"""
tmpl = Template(name=name, type=type, original_file_path=file_path) tmpl = Template(name=name, type=type, original_file_path=file_path)
db.add(tmpl) db.add(tmpl)
db.commit() if commit:
db.commit()
else:
db.flush()
db.refresh(tmpl) db.refresh(tmpl)
return tmpl return tmpl
def save_template_blocks(
db: Session,
template_id: int,
blocks: list[dict],
commit: bool = True,
) -> list[TemplateBlock]:
"""批量保存解析出的模板区域。"""
records = [
TemplateBlock(
template_id=template_id,
block_id=block["block_id"],
parent_block_id=block.get("parent_block_id"),
block_type=block["type"],
block_name=block.get("text", "")[:200] or None,
text_preview=block.get("text", "")[:500] or None,
level=block.get("level", 0),
sort_order=block.get("sort_order", index),
table_rows=block.get("rows"),
table_cols=block.get("cols"),
)
for index, block in enumerate(blocks, start=1)
]
db.add_all(records)
if commit:
db.commit()
else:
db.flush()
return records
+1 -1
View File
@@ -20,7 +20,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioa...n MINIO_ROOT_PASSWORD: minioadmin
ports: ports:
- "9000:9000" # API - "9000:9000" # API
- "9001:9001" # Console - "9001:9001" # Console
@@ -8,96 +8,96 @@
## 第一阶段:MVP 任务总表(73 个) ## 第一阶段:MVP 任务总表(73 个)
| # | 任务名 | 端 | 预估 | | # | 任务名 | 端 | 预估 | 状态 |
|---|--------|:--:|:----:| |---|--------|:--:|:----:|:--:|
| | **环境搭建** | | | | | **环境搭建** | | | |
| 001 | 初始化 FastAPI 项目 + main.py + health 接口 | 后端 | 0.25d | | 001 | 初始化 FastAPI 项目 + main.py + health 接口 | 后端 | 0.25d | ✅ 已完成 |
| 002 | 配置 MySQL 连接 + SQLAlchemy session | 后端 | 0.25d | | 002 | 配置 MySQL 连接 + SQLAlchemy session | 后端 | 0.25d | ✅ 已完成 |
| 003 | 配置 MinIO 客户端 | 后端 | 0.25d | | 003 | 配置 MinIO 客户端 | 后端 | 0.25d | ✅ 已完成 |
| 004 | Alembic 初始化 + 第一个迁移脚本 | 后端 | 0.25d | | 004 | Alembic 初始化 + 第一个迁移脚本 | 后端 | 0.25d | ✅ 已完成 |
| 005 | Docker Compose 编排(MySQL + MinIO | 后端 | 0.25d | | 005 | Docker Compose 编排(MySQL + MinIO | 后端 | 0.25d | ✅ 已完成 |
| 006 | 初始化 Vue3 + Vite 项目 | 前端 | 0.25d | | 006 | 初始化 Vue3 + Vite 项目 | 前端 | 0.25d | ✅ 已完成 |
| 007 | 安装 Ant Design Vue + 全局注册 | 前端 | 0.25d | | 007 | 安装 Ant Design Vue + 全局注册 | 前端 | 0.25d | ✅ 已完成 |
| 008 | 安装 Vue Router + 配置路由结构 | 前端 | 0.25d | | 008 | 安装 Vue Router + 配置路由结构 | 前端 | 0.25d | ✅ 已完成 |
| 009 | 安装 Pinia + 创建根 store | 前端 | 0.25d | | 009 | 安装 Pinia + 创建根 store | 前端 | 0.25d | ✅ 已完成 |
| 010 | 安装 Axios + 创建 API 客户端 base.ts | 前端 | 0.25d | | 010 | 安装 Axios + 创建 API 客户端 base.ts | 前端 | 0.25d | ✅ 已完成 |
| 011 | 定义全局 CSS 变量 | 前端 | 0.25d | | 011 | 定义全局 CSS 变量 | 前端 | 0.25d | ✅ 已完成 |
| | **数据库建表(3 个)** | | | | | **数据库建表(3 个)** | | | |
| 012 | 创建 template 表 + SQLAlchemy Model | 后端 | 0.25d | | 012 | 创建 template 表 + SQLAlchemy Model | 后端 | 0.25d | ✅ 已完成 |
| 013 | 创建 template_block 表 + SQLAlchemy Model | 后端 | 0.25d | | 013 | 创建 template_block 表 + SQLAlchemy Model | 后端 | 0.25d | ✅ 已完成 |
| 014 | 创建 block_config 表 + SQLAlchemy Model(含 unique 约束) | 后端 | 0.25d | | 014 | 创建 block_config 表 + SQLAlchemy Model(含 unique 约束) | 后端 | 0.25d | ✅ 已完成 |
| | **模板上传** | | | | | **模板上传** | | | |
| 015 | 实现文件接收 + .docx 格式校验 + 大小校验 | 后端 | 0.25d | | 015 | 实现文件接收 + .docx 格式校验 + 大小校验 | 后端 | 0.25d | ✅ 已完成 |
| 016 | 实现文件存储到 MinIO | 后端 | 0.25d | | 016 | 实现文件存储到 MinIO | 后端 | 0.25d | ✅ 已完成 |
| 017 | 实现 template 表 insert | 后端 | 0.25d | | 017 | 实现 template 表 insert | 后端 | 0.25d | ✅ 已完成 |
| 018 | 组装上传接口 `POST /api/templates/upload` | 后端 | 0.25d | | 018 | 组装上传接口 `POST /api/templates/upload` | 后端 | 0.25d | ✅ 已完成 |
| | **Word 解析** | | | | | **Word 解析** | | | |
| 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d | | 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d | ✅ 已完成 |
| 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d | | 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d | ✅ 已完成 |
| 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d | | 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d | ✅ 已完成 |
| 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d | | 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d | ✅ 已完成 |
| 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d | | 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d | ✅ 已完成 |
| 024 | 实现层级构建(parent_block_id+ 结构树 tree 输出 | 后端 | 0.25d | | 024 | 实现层级构建(parent_block_id+ 结构树 tree 输出 | 后端 | 0.25d | ✅ 已完成 |
| 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d | | 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d | ✅ 已完成 |
| | **HTML 预览生成** | | | | | **HTML 预览生成** | | | |
| 026 | 标题 → `<h2 data-block-id>` 转换 + 居中加粗样式 | 后端 | 0.25d | | 026 | 标题 → `<h2 data-block-id>` 转换 + 居中加粗样式 | 后端 | 0.25d | ✅ 已完成 |
| 027 | 段落 → `<p data-block-id>` 转换 + 首行缩进样式 | 后端 | 0.25d | | 027 | 段落 → `<p data-block-id>` 转换 + 首行缩进样式 | 后端 | 0.25d | ✅ 已完成 |
| 028 | 表格 → `<table data-block-id>` 转换 + 边框样式 | 后端 | 0.25d | | 028 | 表格 → `<table data-block-id>` 转换 + 边框样式 | 后端 | 0.25d | ✅ 已完成 |
| 029 | 拼接完整 HTML 字符串(带内联 CSS | 后端 | 0.25d | | 029 | 拼接完整 HTML 字符串(带内联 CSS | 后端 | 0.25d | ✅ 已完成 |
| | **查询 + 配置接口** | | | | | **查询 + 配置接口** | | | |
| 030 | 实现 `GET /api/templates/{id}` 组装全部数据 | 后端 | 0.5d | | 030 | 实现 `GET /api/templates/{id}` 组装全部数据 | 后端 | 0.5d | ✅ 已完成 |
| 031 | 实现 `POST /api/templates/{id}/blocks/{blockId}/config` upsert | 后端 | 0.25d | | 031 | 实现 `POST /api/templates/{id}/blocks/{blockId}/config` upsert | 后端 | 0.25d | ✅ 已完成 |
| 032 | 实现 `GET /api/data-sources` 返回预设列表 | 后端 | 0.25d | | 032 | 实现 `GET /api/data-sources` 返回预设列表 | 后端 | 0.25d | ✅ 已完成 |
| 033 | 实现 `POST /api/data-sources` 新增数据源 | 后端 | 0.25d | | 033 | 实现 `POST /api/data-sources` 新增数据源 | 后端 | 0.25d | ✅ 已完成 |
| | **四栏布局** | | | | | **四栏布局** | | | |
| 034 | 实现四栏 CSS Grid 布局 | 前端 | 0.5d | | 034 | 实现四栏 CSS Grid 布局 | 前端 | 0.5d | ✅ 已完成 |
| 035 | 实现左侧菜单 shell 组件 | 前端 | 0.25d | | 035 | 实现左侧菜单 shell 组件 | 前端 | 0.25d | ✅ 已完成 |
| 036 | 实现顶部工具栏 shell 组件 | 前端 | 0.25d | | 036 | 实现顶部工具栏 shell 组件 | 前端 | 0.25d | ✅ 已完成 |
| 037 | 实现配置面板 shell 组件 | 前端 | 0.25d | | 037 | 实现配置面板 shell 组件 | 前端 | 0.25d | ✅ 已完成 |
| 038 | 实现预览区 shell 组件(A4 纸效果) | 前端 | 0.25d | | 038 | 实现预览区 shell 组件(A4 纸效果) | 前端 | 0.25d | ✅ 已完成 |
| 039 | 实现结构树 shell 组件 | 前端 | 0.25d | | 039 | 实现结构树 shell 组件 | 前端 | 0.25d | ✅ 已完成 |
| | **左侧菜单** | | | | | **左侧菜单** | | | |
| 040 | 菜单数据模型(JSON+ 子菜单配置 | 前端 | 0.25d | | 040 | 菜单数据模型(JSON+ 子菜单配置 | 前端 | 0.25d | ✅ 已完成 |
| 041 | 菜单展开/收起交互 + 箭头旋转动画 | 前端 | 0.25d | | 041 | 菜单展开/收起交互 + 箭头旋转动画 | 前端 | 0.25d | ✅ 已完成 |
| 042 | 菜单选中高亮(蓝色左侧竖条) | 前端 | 0.25d | | 042 | 菜单选中高亮(蓝色左侧竖条) | 前端 | 0.25d | ✅ 已完成 |
| | **顶部工具栏** | | | | | **顶部工具栏** | | | |
| 043 | 面包屑导航渲染 | 前端 | 0.25d | | 043 | 面包屑导航渲染 | 前端 | 0.25d | ✅ 已完成 |
| 044 | 模板名称 + 版本号标签展示 | 前端 | 0.25d | | 044 | 模板名称 + 版本号标签展示 | 前端 | 0.25d | ✅ 已完成 |
| 045 | 保存状态指示器(已保存/未保存 圆点切换) | 前端 | 0.25d | | 045 | 保存状态指示器(已保存/未保存 圆点切换) | 前端 | 0.25d | ✅ 已完成 |
| 046 | 操作按钮区(预览/保存/生成测试/导出模板/全屏) | 前端 | 0.25d | | 046 | 操作按钮区(预览/保存/生成测试/导出模板/全屏) | 前端 | 0.25d | ✅ 已完成 |
| | **Word 预览区** | | | | | **Word 预览区** | | | |
| 047 | v-html 渲染后端 HTML + 为 [data-block-id] 添加 doc-block class | 前端 | 0.25d | | 047 | v-html 渲染后端 HTML + 为 [data-block-id] 添加 doc-block class | 前端 | 0.25d | ✅ 已完成 |
| 048 | 点击事件委托 + block_id 提取 | 前端 | 0.25d | | 048 | 点击事件委托 + block_id 提取 | 前端 | 0.25d | ✅ 已完成 |
| 049 | 选中高亮(蓝色虚线 outline + 浅蓝背景) | 前端 | 0.25d | | 049 | 选中高亮(蓝色虚线 outline + 浅蓝背景) | 前端 | 0.25d | ✅ 已完成 |
| 050 | 区域类型颜色映射(蓝/黄/绿/紫/灰)+ 保存后更新 | 前端 | 0.5d | | 050 | 区域类型颜色映射(蓝/黄/绿/紫/灰)+ 保存后更新 | 前端 | 0.5d | ✅ 已完成 |
| 051 | block_id 标签 absolute 定位 + hover 显示 | 前端 | 0.25d | | 051 | block_id 标签 absolute 定位 + hover 显示 | 前端 | 0.25d | ✅ 已完成 |
| 052 | 显示/隐藏标签 toggle 开关 | 前端 | 0.25d | | 052 | 显示/隐藏标签 toggle 开关 | 前端 | 0.25d | ✅ 已完成 |
| 053 | 缩放控制(70%/100%/150% scale 切换) | 前端 | 0.25d | | 053 | 缩放控制(70%/100%/150% scale 切换) | 前端 | 0.25d | ✅ 已完成 |
| | **结构树** | | | | | **结构树** | | | |
| 054 | 递归树组件 + 数据绑定 | 前端 | 0.5d | | 054 | 递归树组件 + 数据绑定 | 前端 | 0.5d | ✅ 已完成 |
| 055 | 展开/折叠交互 + 图标 | 前端 | 0.25d | | 055 | 展开/折叠交互 + 图标 | 前端 | 0.25d | ✅ 已完成 |
| 056 | 状态圆点(已配置绿/待审核黄/已禁用红/未配置灰) | 前端 | 0.25d | | 056 | 状态圆点(已配置绿/待审核黄/已禁用红/未配置灰) | 前端 | 0.25d | ✅ 已完成 |
| 057 | 点击树节点 → 预览区 scrollIntoView + 闪烁高亮 | 前端 | 0.5d | | 057 | 点击树节点 → 预览区 scrollIntoView + 闪烁高亮 | 前端 | 0.5d | ✅ 已完成 |
| | **配置面板** | | | | | **配置面板** | | | |
| 058 | 区域名称 input(默认取 text_preview | 前端 | 0.25d | | 058 | 区域名称 input(默认取 text_preview | 前端 | 0.25d | ✅ 已完成 |
| 059 | 区域类型 select | 前端 | 0.25d | | 059 | 区域类型 select | 前端 | 0.25d | ✅ 已完成 |
| 060 | 数据来源 tag chips 多选组件 | 前端 | 0.5d | | 060 | 数据来源 tag chips 多选组件 | 前端 | 0.5d | ✅ 已完成 |
| 061 | 提示词 textarea + 字数统计 | 前端 | 0.5d | | 061 | 提示词 textarea + 字数统计 | 前端 | 0.5d | ✅ 已完成 |
| 062 | 输出格式 select | 前端 | 0.25d | | 062 | 输出格式 select | 前端 | 0.25d | ✅ 已完成 |
| 063 | 是否需要审核 radio | 前端 | 0.25d | | 063 | 是否需要审核 radio | 前端 | 0.25d | ✅ 已完成 |
| 064 | 备注 textarea + 字数统计 | 前端 | 0.25d | | 064 | 备注 textarea + 字数统计 | 前端 | 0.25d | ✅ 已完成 |
| 065 | 保存按钮 + loading 状态 + 成功/失败提示 | 前端 | 0.25d | | 065 | 保存按钮 + loading 状态 + 成功/失败提示 | 前端 | 0.25d | ✅ 已完成 |
| 066 | 未选中区域时的空状态占位提示 | 前端 | 0.25d | | 066 | 未选中区域时的空状态占位提示 | 前端 | 0.25d | ✅ 已完成 |
| | **Pinia Store** | | | | | **Pinia Store** | | | |
| 067 | templateStore | 前端 | 0.5d | | 067 | templateStore | 前端 | 0.5d | ✅ 已完成 |
| 068 | selectionStore | 前端 | 0.25d | | 068 | selectionStore | 前端 | 0.25d | ✅ 已完成 |
| 069 | uiStore | 前端 | 0.25d | | 069 | uiStore | 前端 | 0.25d | ✅ 已完成 |
| | **三区联动** | | | | | **三区联动** | | | |
| 070 | watch selectedBlockId → 配置面板加载 | 前端 | 0.5d | | 070 | watch selectedBlockId → 配置面板加载 | 前端 | 0.5d | ✅ 已完成 |
| 071 | watch selectedBlockId → 结构树节点高亮 | 前端 | 0.25d | | 071 | watch selectedBlockId → 结构树节点高亮 | 前端 | 0.25d | ✅ 已完成 |
| 072 | 页面初始化 loading(全页遮罩 + a-spin | 前端 | 0.25d | | 072 | 页面初始化 loading(全页遮罩 + a-spin | 前端 | 0.25d | ✅ 已完成 |
| 073 | 加载失败错误页 + 重新加载按钮 | 前端 | 0.25d | | 073 | 加载失败错误页 + 重新加载按钮 | 前端 | 0.25d | ✅ 已完成 |
| | **总计** | | **~18.25d** | | | **总计** | | **~18.25d** | |
--- ---
+201
View File
@@ -0,0 +1,201 @@
# 任务执行摘要
## 会话 ID: 20260701-foundation-frontend
- [2026-07-01 20:19:33]
- **执行原因**: 根据原型图与像素级任务清单,按实施计划补齐当前项目缺失的基础设施任务。
- **执行过程**:
1. 对照任务清单核对现有代码,确认 001、002、005、012-014 已有基础实现,003、004、006-011 存在缺口。
2. 完善 `backend/app/services/storage.py`,新增 MinIO 客户端、`ensure_bucket``upload_file``download_file`,并保留本地文件存储降级与旧函数兼容。
3. 修正 `docker-compose.yml` 中 MinIO 默认密码,使其与后端默认客户端配置一致。
4. 新增 Alembic 配置、迁移环境与首个建表迁移,覆盖 `template``template_block``block_config` 三张表。
5. 新增 `frontend/` Vue 3 + TypeScript + Vite 工程,接入 Ant Design Vue、Vue Router、Pinia、Axios 与全局 CSS 变量。
6. 新增基础模板中心页面,用于验证 Ant Design Vue 按钮、路由渲染、Pinia 状态和全局样式变量接入。
7. 执行后端 Python 语法检查、前端依赖安装、前端生产构建与 Vite 服务可达性检查。
- **执行结果**: 完成任务 003、004、006、007、008、009、010、011 的基础实现;`npm run build` 通过,`http://localhost:5173/` 返回 200,前端开发服务器已启动。
## 会话 ID: 20260701-upload-api
- [2026-07-01 20:26:57]
- **执行原因**: 用户要求将已完成任务在任务列表标注清楚、提交代码,并继续逐个完成后续任务。
- **执行过程**:
1. 将任务清单总表增加状态列,标注 001-014 为已完成,其余任务为未完成。
2. 提交基础设施与前端初始化成果,提交号为 `beec8cf`
3. 继续实现 015-018,新增模板上传 API 路由并挂载到 FastAPI 应用。
4. 复用并完善文件校验、MinIO/本地降级存储与 template 表插入逻辑。
5. 执行 Python 语法检查与 FastAPI 路由导入检查,确认 `/api/templates/upload` 已注册。
- **执行结果**: 完成模板上传接口基础链路,任务 015-018 已在任务清单中标注为已完成。
## 会话 ID: 20260701-doc-parser
- [2026-07-01 20:29:17]
- **执行原因**: 按任务清单继续完成 Word 解析阶段 019-025。
- **执行过程**:
1. 重写 Word block 遍历逻辑,按文档 XML 原始顺序同时遍历段落和表格。
2. 实现 Heading 1-6/标题 1-6 识别、普通段落识别、表格行列数和表头预览提取。
3. 为解析结果顺序生成 `block_001` 形式的 `block_id``sort_order`
4. 基于标题层级计算 `parent_block_id`,并输出树结构节点。
5. 新增 `save_template_blocks`,上传模板后批量写入 `template_block`
6. 使用临时 docx 验证标题、段落、表格混排时的顺序和父子关系。
- **执行结果**: 完成任务 019-025,上传模板时会解析 Word 并保存模板区域块。
## 会话 ID: 20260701-preview-html
- [2026-07-01 20:30:13]
- **执行原因**: 按任务清单继续完成 HTML 预览生成阶段 026-029。
- **执行过程**:
1. 重构 `html_generator.py`,为预览容器、标题、段落、表格定义内联样式。
2. 标题输出为 `<h2 data-block-id>`,支持居中、加粗和下边框样式。
3. 段落输出为 `<p data-block-id>`,支持首行缩进和两端对齐。
4. 表格输出为真实 `<table data-block-id>`,包含表头、单元格边框和占位网格。
5. 增加 HTML 转义,避免 Word 文本中的特殊字符破坏预览结构。
6. 使用示例 blocks 验证标题、段落、表格 HTML 均生成正确。
- **执行结果**: 完成任务 026-029,HTML 预览生成服务已满足基础预览要求。
## 会话 ID: 20260701-query-config-api
- [2026-07-01 20:31:33]
- **执行原因**: 按任务清单继续完成查询与配置接口 030-033。
- **执行过程**:
1. 新增 `BlockConfigPayload``DataSourcePayload` 请求模型。
2. 实现 `GET /api/templates/{template_id}`,聚合模板、预览 HTML、blocks、tree 和 configs。
3. 实现 `POST /api/templates/{template_id}/blocks/{block_id}/config`,按 `(template_id, block_id)` 进行 upsert。
4. 新增 `GET /api/data-sources` 返回 MVP 预设数据源列表。
5. 新增 `POST /api/data-sources` 支持新增内存数据源并校验 code 唯一。
6. 执行语法检查和 FastAPI 路由注册检查。
- **执行结果**: 完成任务 030-033,模板详情、区域配置保存和数据源接口已可调用。
## 会话 ID: 20260701-template-edit-shell
- [2026-07-01 20:34:14]
- **执行原因**: 按任务清单继续完成前端四栏布局和基础 shell 组件 034-039。
- **执行过程**:
1. 新增 `TemplateEdit.vue`,使用 CSS Grid 搭建左侧菜单、顶部工具栏、配置面板、预览区、结构树布局。
2. 新增 `LeftMenu.vue`,实现左侧菜单 shell。
3. 新增 `TopToolbar.vue`,实现面包屑、模板元信息和操作按钮区域 shell。
4. 新增 `ConfigPanel.vue`,实现区域配置面板 shell。
5. 新增 `WordPreview.vue`,实现 A4 纸张预览 shell。
6. 新增 `StructureTree.vue`,实现结构树 shell。
7. 增加 `/templates/:id/edit` 路由,并执行前端构建和浏览器 DOM 检查。
- **执行结果**: 完成任务 034-039,编辑页基础四栏工作台已可访问。
## 会话 ID: 20260701-left-menu
- [2026-07-01 20:35:45]
- **执行原因**: 按任务清单继续完成左侧菜单 040-042。
- **执行过程**:
1. 将左侧菜单改为 JSON 数据模型驱动,包含一级菜单和子菜单配置。
2. 增加分组展开/收起状态,点击一级菜单可切换子菜单显示。
3. 增加箭头旋转动画,展示当前展开状态。
4. 增加选中状态管理,子菜单选中后显示高亮样式。
5. 执行前端构建和浏览器交互检查。
- **执行结果**: 完成任务 040-042,左侧菜单支持数据驱动、展开收起与选中高亮。
## 会话 ID: 20260701-top-toolbar
- [2026-07-01 20:37:24]
- **执行原因**: 按任务清单继续完成顶部工具栏 043-046。
- **执行过程**:
1. 将面包屑、模板名称和版本号改为数据驱动渲染。
2. 增加已保存/未保存状态和状态圆点样式。
3. 绑定保存按钮,可将未保存状态切换为已保存。
4. 保留预览、保存、生成测试、导出模板、全屏 5 个操作按钮。
5. 执行前端构建和浏览器轻量 DOM 检查。
- **执行结果**: 完成任务 043-046,顶部工具栏具备基础展示和保存状态切换能力。
## 会话 ID: 20260701-word-preview
- [2026-07-01 20:39:09]
- **执行原因**: 按任务清单继续完成 Word 预览区 047-053。
- **执行过程**:
1. 将预览区改为 `v-html` 渲染 HTML 字符串。
2. 在渲染后为所有 `[data-block-id]` 元素添加 `doc-block` 和区域类型 class。
3. 使用事件委托提取点击区域的 `block_id`,并维护当前选中块。
4. 增加蓝色虚线 outline 和浅蓝背景作为选中高亮。
5. 增加 AI 生成、人工、固定、表格、未配置区域颜色映射。
6. 使用 absolute 伪元素显示 block_id 标签,并提供显示/隐藏开关。
7. 增加 70%、100%、150% 缩放控制。
8. 执行前端构建和浏览器 DOM/点击选中检查。
- **执行结果**: 完成任务 047-053,Word 预览区支持渲染、选中、标签、颜色和缩放。
## 会话 ID: 20260701-structure-tree
- [2026-07-01 20:41:24]
- **执行原因**: 按任务清单继续完成结构树 054-057。
- **执行过程**:
1. 新增 `StructureTreeNode.vue` 递归树节点组件。
2. 将结构树改为层级数据绑定,支持 children 递归渲染。
3. 增加展开/折叠按钮和箭头旋转状态。
4. 增加已配置、待审核、已禁用、未配置四类状态圆点。
5. 点击树节点后更新高亮,并滚动到预览区对应 `data-block-id` 元素。
6. 增加全局 `tree-flash` 动画用于预览区闪烁提示。
7. 执行前端构建和浏览器 DOM 检查。
- **执行结果**: 完成任务 054-057,结构树支持递归展示、状态、折叠和预览定位。
## 会话 ID: 20260701-config-panel
- [2026-07-01 20:43:43]
- **执行原因**: 按任务清单继续完成配置面板 058-066。
- **执行过程**:
1. 完善区域名称输入框,默认使用当前 block 的 `text_preview`
2. 增加区域类型 select,覆盖 AI 生成、人工填写、固定内容、表格区域。
3. 增加数据来源 tag chips 多选和移除交互。
4. 增加提示词 textarea 和 1000 字字数统计。
5. 增加输出格式 select。
6. 增加是否需要审核 radio。
7. 增加备注 textarea 和 300 字字数统计。
8. 增加保存按钮 loading 状态和成功/失败提示。
9. 增加未选中区域时的空状态占位。
10. 执行前端构建验证。
- **执行结果**: 完成任务 058-066,配置面板具备完整基础表单能力。
## 会话 ID: 20260701-stores-linkage
- [2026-07-01 20:47:36]
- **执行原因**: 按任务清单继续完成 Pinia Store 和三区联动 067-073。
- **执行过程**:
1. 新增 `templateStore`,集中管理模板信息、预览 HTML、blocks、tree、configs、loading 和 error。
2. 新增 `selectionStore`,集中管理当前选中的 `selectedBlockId`
3. 新增 `uiStore`,集中管理标签显示、缩放比例和保存状态。
4. `TemplateEdit.vue` 接入初始化 loading、加载失败错误页和重新加载按钮。
5. `WordPreview.vue` 接入 store,点击预览块会更新全局选中区域。
6. `StructureTree.vue` 接入 store,选中节点会同步高亮并定位预览区。
7. `ConfigPanel.vue` watch `selectedBlockId`,切换区域时自动加载对应配置。
8. `TopToolbar.vue` 接入模板信息和保存状态。
9. 执行前端构建验证。
- **执行结果**: 完成任务 067-073,基础 MVP 任务清单 001-073 已全部标注完成。
## 会话 ID: 20260701-frontend-api-integration
- [2026-07-01 21:47:57]
- **执行原因**: 用户要求提交当前代码后继续推进项目实现。
- **执行过程**:
1. 检查 git 工作区,确认上一轮代码已提交且无未提交变更,因此不创建空提交。
2. 新增前端模板 API 模块,封装 `GET /api/templates/{id}``POST /api/templates/{id}/blocks/{blockId}/config`
3. 改造 `templateStore`,从后端模板详情接口加载模板、预览 HTML、blocks、tree 和 configs。
4. 改造配置保存逻辑,保存时调用后端 block config upsert 接口。
5. 改造 `TemplateEdit.vue`,从路由参数读取模板 ID 后加载对应模板。
6. 执行前端生产构建验证。
- **执行结果**: 前端模板编辑页已从纯 mock 数据推进到后端接口驱动,构建验证通过。
## 会话 ID: 20260701-template-upload-ui
- [2026-07-01 21:48:56]
- **执行原因**: 继续推进模板创建到编辑页的真实使用闭环。
- **执行过程**:
1. 调整 axios base 配置,移除固定 `Content-Type`,支持 JSON 和 multipart 自动识别。
2. 在前端模板 API 模块中新增 `uploadTemplate`,封装 `POST /api/templates/upload`
3. 在模板中心增加上传模板弹窗,包含模板名称、模板类型和 `.docx` 文件选择。
4. 上传成功后展示解析区域数量,并跳转到 `/templates/{template_id}/edit`
5. 上传失败时给出后端服务或数据库未启动的提示。
6. 执行前端生产构建验证。
- **执行结果**: 模板中心“上传模板”按钮已接入后端上传接口,成功后可进入模板编辑页。
## 会话 ID: 20260701-template-list
- [2026-07-01 21:49:52]
- **执行原因**: 继续完善模板中心,使上传后的模板可在列表中查看和进入标注页。
- **执行过程**:
1. 后端新增 `GET /api/templates`,按更新时间和 ID 倒序返回模板列表。
2. 前端模板 API 模块新增 `getTemplates`
3. 模板中心页面初始化时加载模板列表。
4. 将模板中心空态升级为 Ant Design Vue 表格,展示名称、类型、版本、状态、更新时间和标注操作。
5. 增加列表加载失败提示和重试按钮。
6. 上传成功后刷新模板列表并跳转编辑页。
7. 执行后端路由检查和前端生产构建验证。
- **执行结果**: 模板中心已具备模板列表展示、失败重试和进入标注页能力。
## 会话 ID: 20260701-upload-transaction
- [2026-07-01 21:50:22]
- **执行原因**: 继续增强后端上传链路可靠性,避免模板记录和 block 记录出现部分提交。
- **执行过程**:
1.`create_template_record` 增加可选 `commit` 参数,支持在外层事务中只 `flush`
2.`save_template_blocks` 增加可选 `commit` 参数,支持在外层事务中只 `flush`
3. 调整模板上传接口,将 template 创建和 blocks 批量保存放入同一事务后统一 `commit`
4. 执行 Python 语法检查和上传路由注册检查。
- **执行结果**: 模板上传的数据库写入已统一事务提交,降低半成功数据残留风险。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 文档模板系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2012
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ai-doc-template-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@vitejs/plugin-vue": "^5.2.4",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"pinia": "^2.3.1",
"vite": "^5.4.11",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2",
"vue-tsc": "^2.2.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+11
View File
@@ -0,0 +1,11 @@
import axios from "axios";
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api",
timeout: 15000,
});
apiClient.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error),
);
+78
View File
@@ -0,0 +1,78 @@
import { apiClient } from "./base";
import type { BlockConfig } from "@/stores/templateStore";
export type UploadTemplateResponse = {
template_id: number;
name: string;
block_count: number;
status: string;
};
export type TemplateDetailResponse = {
template: {
id: number;
name: string;
version: string;
};
preview_html: string;
blocks: Array<{
block_id: string;
block_type?: string;
type?: string;
text_preview?: string;
text?: string;
level?: number;
sort_order?: number;
table_rows?: number;
table_cols?: number;
}>;
tree: Array<Record<string, unknown>>;
configs: Record<string, BlockConfig>;
};
export type TemplateListItem = {
id: number;
name: string;
type: string;
version: string;
status: number;
created_at?: string | null;
updated_at?: string | null;
};
export async function getTemplates() {
const response = await apiClient.get<{ data: TemplateListItem[]; message: string }>("/templates");
return response.data.data;
}
export async function getTemplateDetail(templateId: number) {
const response = await apiClient.get<{ data: TemplateDetailResponse; message: string }>(
`/templates/${templateId}`,
);
return response.data.data;
}
export async function uploadTemplate(file: File, name: string, type: string) {
const formData = new FormData();
formData.append("file", file);
formData.append("name", name);
formData.append("type", type);
const response = await apiClient.post<{ data: UploadTemplateResponse; message: string }>(
"/templates/upload",
formData,
);
return response.data.data;
}
export async function saveTemplateBlockConfig(
templateId: number,
blockId: string,
config: BlockConfig,
) {
const response = await apiClient.post<{ data: BlockConfig; message: string }>(
`/templates/${templateId}/blocks/${blockId}/config`,
config,
);
return response.data.data;
}
@@ -0,0 +1,314 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { CloseOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { useSelectionStore } from "@/stores/selectionStore";
import { type BlockConfig, type RegionType, useTemplateStore } from "@/stores/templateStore";
import { useUiStore } from "@/stores/uiStore";
const templateStore = useTemplateStore();
const selectionStore = useSelectionStore();
const uiStore = useUiStore();
const selectedBlock = computed(() => templateStore.blockById(selectionStore.selectedBlockId));
const saving = ref(false);
const dataSourceOptions = [
{ code: "policy", name: "政策文件" },
{ code: "business_data", name: "业务数据" },
{ code: "research", name: "调研材料" },
];
const form = reactive<BlockConfig>({
region_name: "",
region_type: "ai_generate" as RegionType,
data_sources: [],
prompt: "",
output_format: "formal_paragraph",
need_review: 1,
remark: "",
enabled: 1,
});
const promptCount = computed(() => form.prompt.length);
const remarkCount = computed(() => form.remark.length);
function addDataSource(code: string) {
if (!form.data_sources.includes(code)) {
form.data_sources.push(code);
}
}
function removeDataSource(code: string) {
form.data_sources = form.data_sources.filter((item) => item !== code);
}
function sourceName(code: string) {
return dataSourceOptions.find((item) => item.code === code)?.name ?? code;
}
function loadSelectedConfig() {
const block = selectedBlock.value;
if (!block) return;
const config = templateStore.configById(block.block_id);
form.region_name = config?.region_name || block.text_preview;
form.region_type = config?.region_type || (block.block_type === "table" ? "table" : "ai_generate");
form.data_sources = [...(config?.data_sources ?? [])];
form.prompt = config?.prompt ?? "";
form.output_format = config?.output_format || "formal_paragraph";
form.need_review = config?.need_review ?? 1;
form.remark = config?.remark ?? "";
form.enabled = config?.enabled ?? 1;
}
async function saveConfig() {
const block = selectedBlock.value;
if (!block) return;
saving.value = true;
try {
await templateStore.saveBlockConfig(block.block_id, { ...form, data_sources: [...form.data_sources] });
uiStore.markSaved();
message.success("配置已保存");
} catch {
message.error("配置保存失败");
} finally {
saving.value = false;
}
}
watch(() => selectionStore.selectedBlockId, loadSelectedConfig, { immediate: true });
</script>
<template>
<aside class="config-panel">
<div class="panel-tabs">
<button class="tab active" type="button">区域配置</button>
<button class="tab" type="button">批量规则</button>
</div>
<div v-if="!selectedBlock" class="empty-state">
<h2>未选择区域</h2>
<p>请选择文档预览中的标题段落或表格</p>
</div>
<div v-else class="panel-body">
<section class="section">
<h2>基础信息</h2>
<label>
区域名称
<a-input v-model:value="form.region_name" :placeholder="selectedBlock.text_preview" />
</label>
<label>
区域类型
<a-select v-model:value="form.region_type">
<a-select-option value="ai_generate">AI 生成</a-select-option>
<a-select-option value="manual">人工填写</a-select-option>
<a-select-option value="fixed">固定内容</a-select-option>
<a-select-option value="table">表格区域</a-select-option>
</a-select>
</label>
</section>
<section class="section">
<h2>数据来源</h2>
<div class="tag-list">
<span v-for="code in form.data_sources" :key="code" class="source-chip">
{{ sourceName(code) }}
<button type="button" aria-label="移除数据源" @click="removeDataSource(code)">
<CloseOutlined />
</button>
</span>
<a-dropdown trigger="click">
<button class="add-chip" type="button">
<PlusOutlined />
添加
</button>
<template #overlay>
<a-menu>
<a-menu-item
v-for="item in dataSourceOptions"
:key="item.code"
:disabled="form.data_sources.includes(item.code)"
@click="addDataSource(item.code)"
>
{{ item.name }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</section>
<section class="section">
<h2>生成规则</h2>
<label>
提示词
<a-textarea v-model:value="form.prompt" :rows="7" :maxlength="1000" />
<span class="count">{{ promptCount }}/1000</span>
</label>
<label>
输出格式
<a-select v-model:value="form.output_format">
<a-select-option value="formal_paragraph">正式段落</a-select-option>
<a-select-option value="bullet_list">要点列表</a-select-option>
<a-select-option value="table_summary">表格摘要</a-select-option>
</a-select>
</label>
<div class="field">
<span class="field-label">是否需要审核</span>
<a-radio-group v-model:value="form.need_review">
<a-radio :value="1">需要</a-radio>
<a-radio :value="0">不需要</a-radio>
</a-radio-group>
</div>
</section>
<section class="section">
<h2>备注</h2>
<label>
<a-textarea v-model:value="form.remark" :rows="4" :maxlength="300" />
<span class="count">{{ remarkCount }}/300</span>
</label>
</section>
<a-button block type="primary" :loading="saving" @click="saveConfig">
<template #icon><SaveOutlined /></template>
保存配置
</a-button>
</div>
</aside>
</template>
<style scoped>
.config-panel {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
border-right: 1px solid var(--c-border);
background: var(--c-surface);
}
.panel-tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--c-border);
}
.tab {
padding: 12px 16px 10px;
border: 0;
border-bottom: 2px solid transparent;
color: var(--c-text-secondary);
background: transparent;
cursor: pointer;
}
.tab.active {
border-bottom-color: var(--c-primary);
color: var(--c-primary);
font-weight: 500;
}
.panel-body {
min-height: 0;
flex: 1;
padding: 16px;
overflow: auto;
}
.section {
margin-bottom: 18px;
}
.section h2 {
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--c-border-light);
font-size: 14px;
}
label,
.field {
display: grid;
gap: 6px;
margin-bottom: 12px;
color: var(--c-text-secondary);
font-size: 12px;
}
.field-label {
font-weight: 500;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.source-chip,
.add-chip {
display: inline-flex;
align-items: center;
height: 26px;
gap: 5px;
padding: 0 9px;
border-radius: 13px;
font-size: 12px;
}
.source-chip {
border: 1px solid var(--c-primary);
color: var(--c-primary);
background: var(--c-primary-soft);
}
.source-chip button,
.add-chip {
border: 0;
background: transparent;
cursor: pointer;
}
.source-chip button {
display: grid;
width: 16px;
height: 16px;
place-items: center;
padding: 0;
color: inherit;
font-size: 10px;
}
.add-chip {
border: 1px dashed var(--c-border);
color: var(--c-text-muted);
}
.count {
justify-self: end;
color: var(--c-text-muted);
font-size: 11px;
}
.empty-state {
display: grid;
flex: 1;
place-content: center;
padding: 32px;
color: var(--c-text-muted);
text-align: center;
}
.empty-state h2 {
margin: 0 0 6px;
color: var(--c-text);
font-size: 15px;
}
.empty-state p {
margin: 0;
}
</style>
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import {
AppstoreOutlined,
DatabaseOutlined,
DownOutlined,
FileTextOutlined,
SettingOutlined,
} from "@ant-design/icons-vue";
type MenuChild = {
key: string;
label: string;
status?: "done" | "empty";
};
type MenuItem = {
key: string;
label: string;
icon: "file" | "app" | "database" | "setting";
children?: MenuChild[];
};
const menuItems: MenuItem[] = [
{
key: "template",
label: "模板管理",
icon: "file",
children: [
{ key: "template-edit", label: "模板标注", status: "done" },
{ key: "template-center", label: "模板中心", status: "empty" },
],
},
{
key: "assets",
label: "资料与配置",
icon: "database",
children: [
{ key: "data-source", label: "数据源", status: "empty" },
{ key: "system-config", label: "系统配置", status: "empty" },
],
},
{
key: "workspace",
label: "工作台",
icon: "app",
},
];
const openKeys = reactive<Record<string, boolean>>({
template: true,
assets: true,
});
const activeKey = ref("template-edit");
function toggleMenu(key: string) {
openKeys[key] = !openKeys[key];
}
function selectItem(key: string) {
activeKey.value = key;
}
</script>
<template>
<aside class="left-menu">
<div class="brand">
<div class="brand-mark">AI</div>
<span>AI 文档模板系统</span>
</div>
<nav class="menu-list">
<div v-for="item in menuItems" :key="item.key" class="menu-group">
<button
class="menu-item"
:class="{ active: activeKey === item.key }"
type="button"
@click="item.children ? toggleMenu(item.key) : selectItem(item.key)"
>
<FileTextOutlined v-if="item.icon === 'file'" />
<AppstoreOutlined v-else-if="item.icon === 'app'" />
<DatabaseOutlined v-else-if="item.icon === 'database'" />
<SettingOutlined v-else />
<span>{{ item.label }}</span>
<DownOutlined
v-if="item.children"
class="menu-arrow"
:class="{ open: openKeys[item.key] }"
/>
</button>
<div v-if="item.children" class="sub-menu" :class="{ open: openKeys[item.key] }">
<button
v-for="child in item.children"
:key="child.key"
class="sub-menu-item"
:class="{ active: activeKey === child.key }"
type="button"
@click="selectItem(child.key)"
>
<span class="sub-dot" :class="child.status"></span>
<span>{{ child.label }}</span>
</button>
</div>
</div>
</nav>
</aside>
</template>
<style scoped>
.left-menu {
min-width: 0;
border-right: 1px solid var(--c-border);
background: var(--c-surface);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 52px;
padding: 0 16px;
border-bottom: 1px solid var(--c-border);
font-weight: 600;
}
.brand-mark {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 5px;
color: #fff;
background: var(--c-primary);
font-size: 13px;
font-weight: 700;
}
.menu-list {
padding: 8px 0;
}
.menu-group {
margin-bottom: 2px;
}
.menu-item {
position: relative;
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 16px;
border: 0;
color: var(--c-text-secondary);
background: transparent;
cursor: pointer;
text-align: left;
}
.menu-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-item:hover {
color: var(--c-text);
background: var(--c-surface-soft);
}
.menu-item.active {
color: var(--c-primary);
background: var(--c-primary-soft);
font-weight: 500;
}
.menu-item.active::before {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--c-primary);
content: "";
}
.menu-arrow {
margin-left: auto;
color: var(--c-text-muted);
font-size: 12px;
transition: transform 0.18s ease;
}
.menu-arrow.open {
transform: rotate(180deg);
}
.sub-menu {
max-height: 0;
overflow: hidden;
transition: max-height 0.22s ease;
}
.sub-menu.open {
max-height: 220px;
}
.sub-menu-item {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 6px 16px 6px 32px;
border: 0;
color: var(--c-text-muted);
background: transparent;
cursor: pointer;
text-align: left;
}
.sub-menu-item:hover {
color: var(--c-text);
background: var(--c-surface-soft);
}
.sub-menu-item.active {
color: var(--c-primary);
font-weight: 500;
}
.sub-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--c-text-muted);
}
.sub-dot.done {
background: var(--c-success);
}
.sub-dot.empty {
background: var(--c-border);
}
</style>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { reactive } from "vue";
import StructureTreeNode, { type StructureNode } from "./StructureTreeNode.vue";
import { useSelectionStore } from "@/stores/selectionStore";
import { useTemplateStore } from "@/stores/templateStore";
const templateStore = useTemplateStore();
const selectionStore = useSelectionStore();
const expandedKeys = reactive<Record<string, boolean>>({
block_001: true,
block_003: true,
});
function toggleNode(id: string) {
expandedKeys[id] = !expandedKeys[id];
}
function selectNode(node: StructureNode) {
selectionStore.selectBlock(node.id);
const target = document.querySelector<HTMLElement>(`[data-block-id="${node.id}"]`);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.classList.add("tree-flash");
window.setTimeout(() => target.classList.remove("tree-flash"), 1600);
}
</script>
<template>
<aside class="structure-tree">
<header>
<span>文档结构</span>
<span class="count">{{ templateStore.blocks.length }}</span>
</header>
<ul class="tree-list">
<StructureTreeNode
v-for="node in templateStore.tree"
:key="node.id"
:node="node"
:active-id="selectionStore.selectedBlockId"
:expanded-keys="expandedKeys"
@select="selectNode"
@toggle="toggleNode"
/>
</ul>
</aside>
</template>
<style scoped>
.structure-tree {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
border-left: 1px solid var(--c-border);
background: var(--c-surface);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
height: 43px;
padding: 0 14px;
border-bottom: 1px solid var(--c-border);
font-weight: 500;
}
.count {
padding: 0 7px;
border-radius: 9px;
color: var(--c-text-muted);
background: var(--c-surface-soft);
font-size: 11px;
line-height: 18px;
}
.tree-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 8px 0;
overflow: auto;
list-style: none;
}
</style>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { RightOutlined } from "@ant-design/icons-vue";
export type StructureNode = {
id: string;
name: string;
status: "done" | "review" | "disabled" | "empty";
children?: StructureNode[];
};
defineProps<{
node: StructureNode;
activeId: string;
expandedKeys: Record<string, boolean>;
level?: number;
}>();
const emit = defineEmits<{
select: [node: StructureNode];
toggle: [id: string];
}>();
</script>
<template>
<li>
<div
class="tree-node"
:class="{ active: activeId === node.id }"
:style="{ paddingLeft: `${10 + (level ?? 0) * 16}px` }"
>
<button
v-if="node.children?.length"
class="toggle-btn"
:class="{ expanded: expandedKeys[node.id] }"
type="button"
:aria-label="expandedKeys[node.id] ? '折叠' : '展开'"
@click.stop="emit('toggle', node.id)"
>
<RightOutlined />
</button>
<span v-else class="toggle-spacer"></span>
<button class="node-main" type="button" @click="emit('select', node)">
<span class="dot" :class="node.status"></span>
<span class="node-name">{{ node.name }}</span>
</button>
</div>
<ul v-if="node.children?.length" v-show="expandedKeys[node.id]" class="tree-children">
<StructureTreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
:active-id="activeId"
:expanded-keys="expandedKeys"
:level="(level ?? 0) + 1"
@select="emit('select', $event)"
@toggle="emit('toggle', $event)"
/>
</ul>
</li>
</template>
<style scoped>
li,
ul {
margin: 0;
padding: 0;
list-style: none;
}
.tree-node {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
padding: 3px 10px;
}
.tree-node.active {
background: var(--c-primary-soft);
color: var(--c-primary);
}
.toggle-btn,
.node-main {
border: 0;
background: transparent;
cursor: pointer;
}
.toggle-btn {
display: grid;
width: 18px;
height: 22px;
place-items: center;
color: var(--c-text-muted);
font-size: 10px;
transition: transform 0.15s ease;
}
.toggle-btn.expanded {
transform: rotate(90deg);
}
.toggle-spacer {
width: 18px;
}
.node-main {
display: grid;
min-width: 0;
grid-template-columns: 7px minmax(0, 1fr);
gap: 7px;
align-items: center;
padding: 3px 2px;
color: inherit;
text-align: left;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-text-muted);
}
.dot.done {
background: var(--c-success);
}
.dot.review {
background: var(--c-warn);
}
.dot.disabled {
background: #d33;
}
.dot.empty {
background: var(--c-border);
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import {
DownloadOutlined,
EyeOutlined,
FullscreenOutlined,
PlayCircleOutlined,
SaveOutlined,
} from "@ant-design/icons-vue";
import { useTemplateStore } from "@/stores/templateStore";
import { useUiStore } from "@/stores/uiStore";
const breadcrumbs = ["模板管理", "模板标注"];
const templateStore = useTemplateStore();
const uiStore = useUiStore();
function markDirty() {
uiStore.markDirty();
}
function saveTemplate() {
uiStore.markSaved();
}
</script>
<template>
<header class="top-toolbar">
<div class="toolbar-left">
<div class="breadcrumb">
<span v-for="item in breadcrumbs" :key="item">{{ item }}</span>
</div>
<div class="template-meta">
<strong>{{ templateStore.template.name }}</strong>
<a-tag color="blue">{{ templateStore.template.version }}</a-tag>
<span class="save-status" :class="{ dirty: !uiStore.isSaved }">
{{ uiStore.saveStatusText }}
</span>
</div>
</div>
<div class="toolbar-actions">
<a-button size="small" @click="markDirty">
<template #icon><EyeOutlined /></template>
预览
</a-button>
<a-button size="small" type="primary" @click="saveTemplate">
<template #icon><SaveOutlined /></template>
保存
</a-button>
<a-button size="small" @click="markDirty">
<template #icon><PlayCircleOutlined /></template>
生成测试
</a-button>
<a-button size="small">
<template #icon><DownloadOutlined /></template>
导出模板
</a-button>
<a-button size="small" shape="circle" aria-label="全屏">
<template #icon><FullscreenOutlined /></template>
</a-button>
</div>
</header>
</template>
<style scoped>
.top-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
padding: 0 16px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
.toolbar-left {
min-width: 0;
}
.breadcrumb {
display: flex;
gap: 4px;
color: var(--c-text-muted);
font-size: 12px;
line-height: 18px;
}
.breadcrumb span + span::before {
margin-right: 4px;
color: var(--c-border);
content: "/";
}
.template-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
line-height: 20px;
}
.template-meta strong {
overflow: hidden;
max-width: 320px;
text-overflow: ellipsis;
white-space: nowrap;
}
.save-status {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--c-text-secondary);
font-size: 12px;
}
.save-status::before {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-success);
content: "";
}
.save-status.dirty::before {
background: var(--c-warn);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from "vue";
import { useSelectionStore } from "@/stores/selectionStore";
import { useTemplateStore } from "@/stores/templateStore";
import { useUiStore } from "@/stores/uiStore";
const zoomOptions = [70, 100, 150];
const previewRef = ref<HTMLElement | null>(null);
const templateStore = useTemplateStore();
const selectionStore = useSelectionStore();
const uiStore = useUiStore();
function enhancePreviewBlocks() {
const root = previewRef.value;
if (!root) return;
root.querySelectorAll<HTMLElement>("[data-block-id]").forEach((element) => {
const blockId = element.dataset.blockId ?? "";
const block = templateStore.blockById(blockId);
const config = templateStore.configById(blockId);
const regionType = config?.region_type ?? (block?.block_type === "table" ? "table" : "none");
element.classList.add("doc-block", `region-${regionType}`);
element.classList.toggle("active", blockId === selectionStore.selectedBlockId);
element.dataset.blockLabel = blockId;
element.dataset.labels = uiStore.showBlockLabels ? "on" : "off";
});
}
async function selectBlock(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const block = target?.closest<HTMLElement>("[data-block-id]");
if (!block) return;
selectionStore.selectBlock(block.dataset.blockId ?? "");
await nextTick();
enhancePreviewBlocks();
}
async function setZoom(value: number) {
uiStore.setZoom(value);
await nextTick();
enhancePreviewBlocks();
}
async function toggleLabels(checked: boolean) {
uiStore.setShowBlockLabels(checked);
await nextTick();
enhancePreviewBlocks();
}
onMounted(() => {
enhancePreviewBlocks();
});
watch(
() => [
templateStore.previewHtml,
selectionStore.selectedBlockId,
uiStore.showBlockLabels,
uiStore.zoom,
Object.keys(templateStore.configs).join(","),
],
async () => {
await nextTick();
enhancePreviewBlocks();
},
);
</script>
<template>
<section class="preview-shell">
<header class="preview-toolbar">
<a-switch size="small" :checked="uiStore.showBlockLabels" @change="toggleLabels" />
<span>显示标签</span>
<div class="zoom-group">
<button
v-for="item in zoomOptions"
:key="item"
type="button"
:class="{ active: uiStore.zoom === item }"
@click="setZoom(item)"
>
{{ item }}%
</button>
</div>
</header>
<div class="preview-wrap">
<div
ref="previewRef"
class="doc-page"
:style="{ transform: `scale(${uiStore.zoom / 100})` }"
@click="selectBlock"
v-html="templateStore.previewHtml"
></div>
</div>
</section>
</template>
<style scoped>
.preview-shell {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 40px minmax(0, 1fr);
background: var(--c-surface-soft);
}
.preview-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: 12px;
}
.zoom-group {
display: flex;
gap: 2px;
margin-left: auto;
padding: 2px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
}
.zoom-group button {
width: 48px;
height: 24px;
border: 0;
border-radius: 3px;
color: var(--c-text-secondary);
background: transparent;
cursor: pointer;
font-size: 12px;
}
.zoom-group button.active {
color: var(--c-primary);
background: var(--c-primary-soft);
font-weight: 500;
}
.preview-wrap {
min-width: 0;
min-height: 0;
padding: 24px;
overflow: auto;
}
.doc-page {
width: 794px;
min-height: 1123px;
margin: 0 auto;
padding: 72px 64px;
transform-origin: top center;
background: var(--c-surface);
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
color: var(--c-text);
font-size: 14px;
line-height: 1.8;
}
:deep(.doc-block) {
position: relative;
border-radius: 4px;
cursor: pointer;
outline: 1px solid transparent;
outline-offset: 2px;
transition: background 0.15s ease, outline-color 0.15s ease;
}
:deep(.doc-block:hover) {
background: rgb(91 91 214 / 7%);
}
:deep(.doc-block.active) {
background: rgb(91 91 214 / 12%);
outline: 2px dashed var(--c-primary);
}
:deep(.region-ai_generate) {
box-shadow: inset 3px 0 0 #5b5bd6;
}
:deep(.region-manual) {
box-shadow: inset 3px 0 0 #d48a00;
}
:deep(.region-fixed) {
box-shadow: inset 3px 0 0 #1a8c4a;
}
:deep(.region-table) {
box-shadow: inset 3px 0 0 #7c3aed;
}
:deep(.region-none) {
box-shadow: inset 3px 0 0 #c4c8cf;
}
:deep(.doc-block::before) {
position: absolute;
top: 0;
left: -88px;
z-index: 2;
padding: 1px 6px;
border: 1px solid var(--c-primary);
border-radius: 3px;
color: var(--c-primary);
background: var(--c-primary-soft);
content: attr(data-block-label);
font-size: 10px;
line-height: 18px;
opacity: 0;
pointer-events: none;
white-space: nowrap;
transition: opacity 0.15s ease;
}
:deep(.doc-block:hover::before),
:deep(.doc-block[data-labels="on"]::before) {
opacity: 1;
}
:deep(.doc-block[data-labels="off"]::before) {
opacity: 0;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import App from "./App.vue";
import router from "./router";
import "./styles/variables.css";
import "./styles/global.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(Antd);
app.mount("#app");
+22
View File
@@ -0,0 +1,22 @@
import { createRouter, createWebHistory } from "vue-router";
import TemplateCenter from "@/views/TemplateCenter.vue";
import TemplateEdit from "@/views/TemplateEdit.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
name: "template-center",
component: TemplateCenter,
},
{
path: "/templates/:id/edit",
name: "template-edit",
component: TemplateEdit,
},
],
});
export default router;
+8
View File
@@ -0,0 +1,8 @@
import { defineStore } from "pinia";
export const useAppStore = defineStore("app", {
state: () => ({
projectName: "AI 文档模板系统",
saveStatus: "已保存",
}),
});
+12
View File
@@ -0,0 +1,12 @@
import { defineStore } from "pinia";
export const useSelectionStore = defineStore("selection", {
state: () => ({
selectedBlockId: "block_003",
}),
actions: {
selectBlock(blockId: string) {
this.selectedBlockId = blockId;
},
},
});
+164
View File
@@ -0,0 +1,164 @@
import { defineStore } from "pinia";
import { getTemplateDetail, saveTemplateBlockConfig } from "@/api/templates";
export type RegionType = "ai_generate" | "manual" | "fixed" | "table";
export type TemplateBlock = {
block_id: string;
block_type: "title" | "heading" | "paragraph" | "table";
text_preview: string;
level: number;
sort_order: number;
table_rows?: number;
table_cols?: number;
};
export type TreeNode = {
id: string;
name: string;
status: "done" | "review" | "disabled" | "empty";
children?: TreeNode[];
};
export type BlockConfig = {
region_name: string;
region_type: RegionType;
data_sources: string[];
prompt: string;
output_format: string;
need_review: number;
remark: string;
enabled: number;
};
const mockPreviewHtml = `
<h1 data-block-id="block_001" style="margin:0 0 28px;text-align:center;font-size:20px;">企业经营分析报告</h1>
<p data-block-id="block_002" style="margin:8px 0;text-align:justify;text-indent:2em;">本报告用于展示 Word 模板解析后的 A4 预览效果,后续会由后端返回的 preview_html 驱动渲染。</p>
<h2 data-block-id="block_003" style="margin:24px 0 12px;padding-bottom:6px;border-bottom:2px solid #1a1d24;font-size:16px;">一、经营概况</h2>
<p data-block-id="block_004" style="margin:8px 0;text-align:justify;text-indent:2em;">点击预览区块后,左侧配置面板将展示对应区域的提示词、数据源和输出格式配置。</p>
<table data-block-id="block_005" style="width:100%;margin:14px 0;border-collapse:collapse;">
<thead><tr>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">指标</th>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">本期</th>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">同比</th>
</tr></thead>
<tbody>
<tr><td style="padding:7px 10px;border:1px solid #e0e2e6;">营业收入</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td></tr>
<tr><td style="padding:7px 10px;border:1px solid #e0e2e6;">利润率</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td></tr>
</tbody>
</table>
`;
function normalizeTreeNode(node: Record<string, unknown>): TreeNode {
const id = String(node.id ?? node.block_id ?? "");
const name = String(node.name ?? node.text ?? node.text_preview ?? id);
const children = Array.isArray(node.children)
? node.children.map((child) => normalizeTreeNode(child as Record<string, unknown>))
: undefined;
return {
id,
name,
status: "empty",
children,
};
}
export const useTemplateStore = defineStore("template", {
state: () => ({
loading: false,
error: "",
template: {
id: 1,
name: "企业经营分析报告模板",
version: "v1.0.0",
},
previewHtml: mockPreviewHtml,
blocks: [
{ block_id: "block_001", block_type: "title", text_preview: "企业经营分析报告", level: 0, sort_order: 1 },
{ block_id: "block_002", block_type: "paragraph", text_preview: "报告说明", level: 0, sort_order: 2 },
{ block_id: "block_003", block_type: "heading", text_preview: "一、经营概况", level: 1, sort_order: 3 },
{ block_id: "block_004", block_type: "paragraph", text_preview: "经营概况段落", level: 0, sort_order: 4 },
{ block_id: "block_005", block_type: "table", text_preview: "经营指标表", level: 0, sort_order: 5, table_rows: 3, table_cols: 3 },
] as TemplateBlock[],
tree: [
{
id: "block_001",
name: "企业经营分析报告",
status: "done",
children: [
{ id: "block_002", name: "报告说明", status: "review" },
{
id: "block_003",
name: "一、经营概况",
status: "done",
children: [
{ id: "block_004", name: "经营概况段落", status: "empty" },
{ id: "block_005", name: "经营指标表", status: "disabled" },
],
},
],
},
] as TreeNode[],
configs: {
block_003: {
region_name: "一、经营概况",
region_type: "ai_generate",
data_sources: ["business_data"],
prompt: "请基于业务数据生成经营概况,突出核心指标变化和原因。",
output_format: "formal_paragraph",
need_review: 1,
remark: "",
enabled: 1,
},
block_005: {
region_name: "经营指标表",
region_type: "table",
data_sources: ["business_data"],
prompt: "",
output_format: "table_summary",
need_review: 1,
remark: "",
enabled: 0,
},
} as Record<string, BlockConfig>,
}),
getters: {
blockById: (state) => (blockId: string) => state.blocks.find((block) => block.block_id === blockId),
configById: (state) => (blockId: string) => state.configs[blockId],
},
actions: {
async loadTemplate(templateId = 1) {
this.loading = true;
this.error = "";
try {
const detail = await getTemplateDetail(templateId);
this.template = {
id: detail.template.id,
name: detail.template.name,
version: detail.template.version,
};
this.previewHtml = detail.preview_html;
this.blocks = detail.blocks.map((block) => ({
block_id: block.block_id,
block_type: (block.block_type ?? block.type ?? "paragraph") as TemplateBlock["block_type"],
text_preview: block.text_preview ?? block.text ?? "",
level: block.level ?? 0,
sort_order: block.sort_order ?? 0,
table_rows: block.table_rows,
table_cols: block.table_cols,
}));
this.tree = detail.tree.map((node) => normalizeTreeNode(node));
this.configs = detail.configs;
} catch (error) {
this.error = error instanceof Error ? error.message : "模板加载失败";
} finally {
this.loading = false;
}
},
async saveBlockConfig(blockId: string, config: BlockConfig) {
const savedConfig = await saveTemplateBlockConfig(this.template.id, blockId, config);
this.configs[blockId] = { ...savedConfig };
},
},
});
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from "pinia";
export const useUiStore = defineStore("ui", {
state: () => ({
showBlockLabels: true,
zoom: 100,
isSaved: true,
}),
getters: {
saveStatusText: (state) => (state.isSaved ? "已保存" : "未保存"),
},
actions: {
setZoom(value: number) {
this.zoom = value;
},
setShowBlockLabels(value: boolean) {
this.showBlockLabels = value;
},
markDirty() {
this.isSaved = false;
},
markSaved() {
this.isSaved = true;
},
},
});
+39
View File
@@ -0,0 +1,39 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1024px;
min-height: 100vh;
color: var(--c-text);
background: var(--c-bg);
font-family: var(--font-app);
font-size: 13px;
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea,
select {
font: inherit;
}
.doc-block.tree-flash {
animation: tree-flash 1.6s ease;
}
@keyframes tree-flash {
0%,
100% {
outline-color: var(--c-primary);
}
50% {
background: #fff7d6;
outline-color: var(--c-warn);
}
}
+19
View File
@@ -0,0 +1,19 @@
:root {
--c-primary: #5b5bd6;
--c-primary-hover: #4a4ac0;
--c-primary-soft: #eeeefb;
--c-bg: #f5f6f8;
--c-surface: #ffffff;
--c-surface-soft: #f0f1f3;
--c-border: #e0e2e6;
--c-border-light: #eaecef;
--c-text: #1a1d24;
--c-text-secondary: #5b626e;
--c-text-muted: #9aa1ad;
--c-success: #1a8c4a;
--c-warn: #d48a00;
--radius-sm: 4px;
--radius-md: 6px;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 6%);
--font-app: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
}
+427
View File
@@ -0,0 +1,427 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { EditOutlined, FileTextOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { getTemplates, type TemplateListItem, uploadTemplate } from "@/api/templates";
import { useAppStore } from "@/stores/appStore";
const appStore = useAppStore();
const router = useRouter();
const statusClass = computed(() => (appStore.saveStatus === "已保存" ? "saved" : "dirty"));
const uploadOpen = ref(false);
const uploading = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const loading = ref(false);
const loadError = ref("");
const templates = ref<TemplateListItem[]>([]);
const form = ref({
name: "",
type: "report",
});
const tableColumns = [
{ title: "模板名称", dataIndex: "name", key: "name" },
{ title: "类型", dataIndex: "type", key: "type", width: 120 },
{ title: "版本", dataIndex: "version", key: "version", width: 120 },
{ title: "状态", dataIndex: "status", key: "status", width: 100 },
{ title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 190 },
{ title: "操作", key: "actions", width: 110 },
];
onMounted(() => {
loadTemplates();
});
async function loadTemplates() {
loading.value = true;
loadError.value = "";
try {
templates.value = await getTemplates();
} catch {
loadError.value = "模板列表加载失败,请确认后端服务和数据库已启动";
} finally {
loading.value = false;
}
}
function openUploadModal() {
uploadOpen.value = true;
}
function chooseFile() {
fileInputRef.value?.click();
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
selectedFile.value = file;
if (file && !form.value.name) {
form.value.name = file.name.replace(/\.docx$/i, "");
}
}
async function submitUpload() {
if (!selectedFile.value) {
message.warning("请先选择 .docx 文件");
return;
}
if (!form.value.name.trim()) {
message.warning("请输入模板名称");
return;
}
uploading.value = true;
try {
const result = await uploadTemplate(selectedFile.value, form.value.name.trim(), form.value.type);
message.success(`模板上传成功,解析 ${result.block_count} 个区域`);
uploadOpen.value = false;
await loadTemplates();
await router.push(`/templates/${result.template_id}/edit`);
} catch {
message.error("模板上传失败,请确认后端服务和数据库已启动");
} finally {
uploading.value = false;
}
}
function openEditor(templateId: number) {
router.push(`/templates/${templateId}/edit`);
}
function formatTime(value?: string | null) {
if (!value) return "-";
return value.replace("T", " ").slice(0, 19);
}
</script>
<template>
<main class="template-center">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">AI</div>
<span>{{ appStore.projectName }}</span>
</div>
<nav class="nav-list">
<button class="nav-item active" type="button">
<FileTextOutlined />
模板中心
</button>
</nav>
</aside>
<section class="workspace">
<header class="toolbar">
<div>
<div class="breadcrumb">模板管理 / 模板中心</div>
<h1>模板中心</h1>
</div>
<div class="toolbar-actions">
<span class="save-status" :class="statusClass">{{ appStore.saveStatus }}</span>
<a-button type="primary" @click="openUploadModal">
<template #icon><PlusOutlined /></template>
上传模板
</a-button>
</div>
</header>
<section class="content">
<a-alert
v-if="loadError"
class="list-alert"
type="error"
show-icon
:message="loadError"
>
<template #action>
<a-button size="small" @click="loadTemplates">
<template #icon><ReloadOutlined /></template>
重试
</a-button>
</template>
</a-alert>
<a-table
v-if="templates.length"
:columns="tableColumns"
:data-source="templates"
:loading="loading"
:pagination="{ pageSize: 8 }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<strong>{{ record.name }}</strong>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? "启用" : "停用" }}
</a-tag>
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatTime(record.updated_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" type="link" @click="openEditor(record.id)">
<template #icon><EditOutlined /></template>
标注
</a-button>
</template>
</template>
</a-table>
<div v-else class="empty-panel">
<FileTextOutlined class="empty-icon" />
<h2>{{ loading ? "正在加载模板" : "暂无模板" }}</h2>
<p>{{ loadError ? "可点击重试或先上传一个 Word 模板。" : "上传 Word 模板后即可进入标注配置页。" }}</p>
</div>
</section>
</section>
<a-modal
v-model:open="uploadOpen"
title="上传模板"
:confirm-loading="uploading"
ok-text="上传"
cancel-text="取消"
@ok="submitUpload"
>
<div class="upload-form">
<label>
模板名称
<a-input v-model:value="form.name" placeholder="请输入模板名称" />
</label>
<label>
模板类型
<a-select v-model:value="form.type">
<a-select-option value="report">报告类</a-select-option>
<a-select-option value="official">公文类</a-select-option>
<a-select-option value="summary">总结类</a-select-option>
<a-select-option value="contract">合同类</a-select-option>
</a-select>
</label>
<div class="file-picker">
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept=".docx"
@change="handleFileChange"
/>
<button type="button" @click="chooseFile">
<FileTextOutlined />
{{ selectedFile ? selectedFile.name : "选择 .docx 文件" }}
</button>
</div>
</div>
</a-modal>
</main>
</template>
<style scoped>
.template-center {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 220px;
flex: 0 0 220px;
border-right: 1px solid var(--c-border);
background: var(--c-surface);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 52px;
padding: 0 16px;
border-bottom: 1px solid var(--c-border);
font-weight: 600;
}
.brand-mark {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 5px;
color: #fff;
background: var(--c-primary);
font-size: 13px;
font-weight: 700;
}
.nav-list {
padding: 8px 0;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 16px;
border: 0;
color: var(--c-text-secondary);
background: transparent;
cursor: pointer;
text-align: left;
}
.nav-item.active {
color: var(--c-primary);
background: var(--c-primary-soft);
font-weight: 500;
}
.nav-item.active::before {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--c-primary);
content: "";
}
.workspace {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 24px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
.breadcrumb {
color: var(--c-text-muted);
font-size: 12px;
}
h1 {
margin: 2px 0 0;
font-size: 18px;
line-height: 1.3;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.save-status {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--c-text-secondary);
font-size: 12px;
}
.save-status::before {
width: 7px;
height: 7px;
border-radius: 50%;
content: "";
}
.save-status.saved::before {
background: var(--c-success);
}
.save-status.dirty::before {
background: var(--c-warn);
}
.content {
flex: 1;
padding: 24px;
overflow: auto;
}
.list-alert {
margin-bottom: 16px;
}
.empty-panel {
display: grid;
min-height: 320px;
place-items: center;
align-content: center;
gap: 8px;
border: 1px dashed var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
color: var(--c-text-secondary);
box-shadow: var(--shadow-sm);
}
.empty-icon {
color: var(--c-primary);
font-size: 30px;
}
.empty-panel h2 {
margin: 4px 0 0;
color: var(--c-text);
font-size: 16px;
}
.empty-panel p {
margin: 0;
color: var(--c-text-muted);
}
.upload-form {
display: grid;
gap: 14px;
}
.upload-form label {
display: grid;
gap: 6px;
color: var(--c-text-secondary);
font-size: 12px;
}
.hidden-input {
display: none;
}
.file-picker button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 88px;
gap: 8px;
border: 1px dashed var(--c-border);
border-radius: var(--radius-md);
color: var(--c-text-secondary);
background: var(--c-surface);
cursor: pointer;
}
.file-picker button:hover {
border-color: var(--c-primary);
color: var(--c-primary);
background: var(--c-primary-soft);
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRoute } from "vue-router";
import ConfigPanel from "@/components/TemplateEdit/ConfigPanel.vue";
import LeftMenu from "@/components/TemplateEdit/LeftMenu.vue";
import StructureTree from "@/components/TemplateEdit/StructureTree.vue";
import TopToolbar from "@/components/TemplateEdit/TopToolbar.vue";
import WordPreview from "@/components/TemplateEdit/WordPreview.vue";
import { useTemplateStore } from "@/stores/templateStore";
const templateStore = useTemplateStore();
const route = useRoute();
onMounted(() => {
const templateId = Number(route.params.id || 1);
templateStore.loadTemplate(Number.isFinite(templateId) ? templateId : 1);
});
</script>
<template>
<main class="template-edit">
<LeftMenu />
<section class="edit-main">
<TopToolbar />
<div v-if="templateStore.loading" class="state-view">
<a-spin size="large" />
</div>
<div v-else-if="templateStore.error" class="state-view">
<a-result status="error" title="加载失败" :sub-title="templateStore.error">
<template #extra>
<a-button type="primary" @click="templateStore.loadTemplate()">重新加载</a-button>
</template>
</a-result>
</div>
<div v-else class="edit-workspace">
<ConfigPanel />
<WordPreview />
<StructureTree />
</div>
</section>
</main>
</template>
<style scoped>
.template-edit {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
height: 100vh;
overflow: hidden;
background: var(--c-bg);
}
.edit-main {
display: grid;
min-width: 0;
grid-template-rows: 48px minmax(0, 1fr);
}
.edit-workspace {
display: grid;
min-height: 0;
grid-template-columns: 380px minmax(520px, 1fr) 240px;
overflow: hidden;
}
.state-view {
display: grid;
min-height: 0;
place-items: center;
background: var(--c-bg);
}
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
host: "0.0.0.0",
port: 5173,
},
});