Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f87fbb62 | |||
| 1ff0fdf602 | |||
| 5948334d15 | |||
| 5a24edc3b0 | |||
| 60bdd5f268 | |||
| 04b5536698 | |||
| 7a1e069139 | |||
| 5da6f7f3d0 | |||
| 65e10064ea | |||
| ffa869e086 | |||
| b4e128c227 | |||
| 217af9bc99 | |||
| 611e089c9e | |||
| 389b9c0c92 | |||
| 95c507536a | |||
| beec8cf5bb |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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"}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
return int(parts) if parts.isdigit() and 1 <= int(parts) <= 6 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def classify_table(table: DocxTable) -> Dict[str, Any] | None:
|
||||||
|
"""识别表格类型并提取行列数。"""
|
||||||
|
rows = len(table.rows)
|
||||||
|
cols = len(table.columns)
|
||||||
|
if rows == 0 or cols == 0:
|
||||||
|
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",
|
"type": "table",
|
||||||
"text": " | ".join(headers),
|
"text": text,
|
||||||
"level": 0,
|
"level": 0,
|
||||||
"rows": len(table.rows),
|
"rows": rows,
|
||||||
"cols": len(table.columns),
|
"cols": cols,
|
||||||
"headers": headers,
|
"headers": first_row,
|
||||||
"table_index": i,
|
}
|
||||||
})
|
|
||||||
return tables
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
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
|
|
||||||
|
|
||||||
# 非叶子节点入栈
|
heading_stack.append({"level": level, "node": node})
|
||||||
if level < 2 or block["type"] == "heading":
|
elif heading_stack:
|
||||||
stack.append({"level": level, "node": node})
|
parent = heading_stack[-1]["node"]
|
||||||
|
parent["children"].append(node)
|
||||||
|
block["parent_block_id"] = parent["block_id"]
|
||||||
|
else:
|
||||||
|
tree.append(node)
|
||||||
|
|
||||||
return {"blocks": tree}
|
return tree
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
if commit:
|
||||||
db.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
@@ -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** | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 语法检查和上传路由注册检查。
|
||||||
|
- **执行结果**: 模板上传的数据库写入已统一事务提交,降低半成功数据残留风险。
|
||||||
@@ -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>
|
||||||
Generated
+2012
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
@@ -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");
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useAppStore = defineStore("app", {
|
||||||
|
state: () => ({
|
||||||
|
projectName: "AI 文档模板系统",
|
||||||
|
saveStatus: "已保存",
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useSelectionStore = defineStore("selection", {
|
||||||
|
state: () => ({
|
||||||
|
selectedBlockId: "block_003",
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
selectBlock(blockId: string) {
|
||||||
|
this.selectedBlockId = blockId;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user