实现模板解析与模板管理基础链路
This commit is contained in:
+14
-3
@@ -1,7 +1,8 @@
|
||||
import uvicorn
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from database import init_db, engine
|
||||
from config import settings
|
||||
from routers import templates, models, generate, export
|
||||
@@ -32,14 +33,24 @@ app.include_router(generate.router, prefix="/api/v1/generate", tags=["生成管
|
||||
app.include_router(export.router, prefix="/api/v1/export", tags=["导出管理"])
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_: Request, exc: HTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content={"code": -1, "message": exc.detail})
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(_: Request, exc: Exception):
|
||||
return JSONResponse(status_code=500, content={"code": -1, "message": str(exc) or "服务器内部错误"})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "AI 文档模板生成系统 API", "version": settings.APP_VERSION}
|
||||
return {"code": 0, "data": {"name": settings.APP_NAME, "version": settings.APP_VERSION}, "message": "ok"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
return {"code": 0, "data": {"status": "ok"}, "message": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from models.paragraph import Paragraph
|
||||
from models.template import Template
|
||||
from schemas.schemas import Response, TemplateSave
|
||||
from services.minio_client import minio_client
|
||||
from services.template_parser import parse_template
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_object_path(filename: str) -> tuple[str, str]:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
date_prefix = datetime.now().strftime("%Y%m%d")
|
||||
object_name = f"{date_prefix}/{uuid.uuid4().hex}{ext}"
|
||||
return ext, object_name
|
||||
|
||||
|
||||
def _serialize_paragraph(paragraph: Paragraph) -> dict:
|
||||
return {
|
||||
"id": paragraph.id,
|
||||
"template_id": paragraph.template_id,
|
||||
"sort_index": paragraph.sort_index,
|
||||
"title": paragraph.title,
|
||||
"content": paragraph.content,
|
||||
"style_json": paragraph.style_json,
|
||||
"is_table": paragraph.is_table,
|
||||
"table_json": paragraph.table_json,
|
||||
"edit_mode": paragraph.edit_mode,
|
||||
"model_id": paragraph.model_id,
|
||||
"need_prompt": paragraph.need_prompt,
|
||||
"prompt_text": paragraph.prompt_text,
|
||||
"need_file": paragraph.need_file,
|
||||
"file_note": paragraph.file_note,
|
||||
"output_format": paragraph.output_format,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_template(template: Template) -> dict:
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"file_path": template.file_path,
|
||||
"paragraph_count": template.paragraph_count,
|
||||
"status": template.status,
|
||||
"created_at": template.created_at,
|
||||
"updated_at": template.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
keyword: str = Query("", alias="q"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
filters = []
|
||||
if keyword:
|
||||
filters.append(Template.name.like(f"%{keyword}%"))
|
||||
|
||||
total_stmt = select(func.count(Template.id))
|
||||
list_stmt = select(Template).order_by(Template.id.desc())
|
||||
if filters:
|
||||
total_stmt = total_stmt.where(*filters)
|
||||
list_stmt = list_stmt.where(*filters)
|
||||
|
||||
total = (await db.execute(total_stmt)).scalar_one()
|
||||
result = await db.execute(list_stmt.offset((page - 1) * page_size).limit(page_size))
|
||||
items = [_serialize_template(item) for item in result.scalars().all()]
|
||||
return Response(
|
||||
data={"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
template = await db.get(Template, template_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
result = await db.execute(
|
||||
select(Paragraph)
|
||||
.where(Paragraph.template_id == template_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
)
|
||||
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
|
||||
payload = _serialize_template(template)
|
||||
payload["paragraphs"] = paragraphs
|
||||
return Response(data=payload)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="文件名不能为空")
|
||||
|
||||
ext, object_name = _build_object_path(file.filename)
|
||||
if ext != ".docx":
|
||||
raise HTTPException(status_code=400, detail="模板仅支持 .docx 格式")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="上传文件不能为空")
|
||||
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="文件大小超过限制")
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
parsed_items = await asyncio.to_thread(parse_template, temp_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
await asyncio.to_thread(
|
||||
minio_client.put_object,
|
||||
settings.MINIO_BUCKET_TEMPLATES,
|
||||
object_name,
|
||||
BytesIO(content),
|
||||
len(content),
|
||||
file.content_type or "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
|
||||
template = Template(
|
||||
name=os.path.splitext(file.filename)[0],
|
||||
description="",
|
||||
file_path=f"{settings.MINIO_BUCKET_TEMPLATES}/{object_name}",
|
||||
paragraph_count=len(parsed_items),
|
||||
status="draft",
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
paragraph_rows: list[Paragraph] = []
|
||||
for item in parsed_items:
|
||||
paragraph = Paragraph(
|
||||
template_id=template.id,
|
||||
sort_index=item.sort_index,
|
||||
title=item.title,
|
||||
content=item.content,
|
||||
style_json=item.style_json,
|
||||
is_table=item.is_table,
|
||||
table_json=item.table_json,
|
||||
)
|
||||
db.add(paragraph)
|
||||
paragraph_rows.append(paragraph)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
for paragraph in paragraph_rows:
|
||||
await db.refresh(paragraph)
|
||||
|
||||
payload = _serialize_template(template)
|
||||
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
|
||||
return Response(data=payload)
|
||||
|
||||
|
||||
@router.put("/{template_id}/paragraphs")
|
||||
async def save_template_paragraphs(
|
||||
template_id: int,
|
||||
body: TemplateSave,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
template = await db.get(Template, template_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
||||
paragraph_map = {item.id: item for item in result.scalars().all()}
|
||||
|
||||
for config in body.paragraphs:
|
||||
paragraph = paragraph_map.get(config.id)
|
||||
if paragraph is None:
|
||||
continue
|
||||
paragraph.sort_index = config.sort_index
|
||||
paragraph.title = config.title
|
||||
paragraph.edit_mode = config.edit_mode
|
||||
paragraph.model_id = config.model_id
|
||||
paragraph.need_prompt = config.need_prompt
|
||||
paragraph.prompt_text = config.prompt_text
|
||||
paragraph.need_file = config.need_file
|
||||
paragraph.file_note = config.file_note
|
||||
paragraph.output_format = config.output_format
|
||||
|
||||
await db.commit()
|
||||
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
template = await db.get(Template, template_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
||||
for paragraph in result.scalars().all():
|
||||
await db.delete(paragraph)
|
||||
|
||||
file_path = template.file_path or ""
|
||||
if "/" in file_path:
|
||||
bucket, object_name = file_path.split("/", 1)
|
||||
try:
|
||||
await asyncio.to_thread(minio_client.remove_object, bucket, object_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await db.delete(template)
|
||||
await db.commit()
|
||||
return Response(data={"id": template_id})
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from minio import Minio
|
||||
from config import settings
|
||||
|
||||
# MinIO 客户端
|
||||
minio_client = Minio(
|
||||
settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=settings.MINIO_USE_SSL,
|
||||
)
|
||||
|
||||
|
||||
async def init_buckets():
|
||||
"""初始化 MinIO 存储桶(应用启动时调用)"""
|
||||
buckets = [
|
||||
settings.MINIO_BUCKET_TEMPLATES, # 原始模板文件
|
||||
settings.MINIO_BUCKET_UPLOADS, # 用户上传的参考文件
|
||||
settings.MINIO_BUCKET_OUTPUTS, # 生成的文档
|
||||
]
|
||||
for bucket in buckets:
|
||||
if not minio_client.bucket_exists(bucket):
|
||||
minio_client.make_bucket(bucket)
|
||||
print(f"[MinIO] 创建存储桶: {bucket}")
|
||||
|
||||
|
||||
def get_file_url(bucket: str, object_name: str) -> str:
|
||||
"""获取文件的公开访问 URL"""
|
||||
if settings.MINIO_USE_SSL:
|
||||
protocol = "https"
|
||||
else:
|
||||
protocol = "http"
|
||||
return f"{protocol}://{settings.MINIO_ENDPOINT}/{bucket}/{object_name}"
|
||||
|
||||
|
||||
def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str:
|
||||
"""获取预签名下载 URL(带过期时间)"""
|
||||
return minio_client.presigned_get_object(bucket, object_name, expires=expires)
|
||||
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from docx import Document
|
||||
from docx.document import Document as DocumentObject
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml.table import CT_Tbl
|
||||
from docx.oxml.text.paragraph import CT_P
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedParagraph:
|
||||
sort_index: int
|
||||
title: str
|
||||
content: str
|
||||
style_json: str
|
||||
is_table: bool
|
||||
table_json: str
|
||||
|
||||
|
||||
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
|
||||
body = document.element.body
|
||||
for child in body.iterchildren():
|
||||
if isinstance(child, CT_P):
|
||||
yield Paragraph(child, document)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
yield Table(child, document)
|
||||
|
||||
|
||||
def _safe_pt(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(value.pt), 2)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_indent(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(value.pt), 2)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
def _alignment_name(value: WD_ALIGN_PARAGRAPH | None) -> str:
|
||||
if value is None:
|
||||
return "LEFT"
|
||||
return getattr(value, "name", "LEFT")
|
||||
|
||||
|
||||
def _heading_level(style_name: str) -> int | None:
|
||||
if not style_name:
|
||||
return None
|
||||
normalized = style_name.lower().replace(" ", "")
|
||||
if normalized.startswith("heading"):
|
||||
level = normalized.replace("heading", "")
|
||||
if level.isdigit():
|
||||
return int(level)
|
||||
return None
|
||||
|
||||
|
||||
def _get_run_font_info(paragraph: Paragraph) -> dict:
|
||||
for run in paragraph.runs:
|
||||
if not run.text.strip():
|
||||
continue
|
||||
r_fonts = getattr(run._element.rPr, "rFonts", None) if run._element.rPr is not None else None
|
||||
east_asia = r_fonts.get(qn("w:eastAsia")) if r_fonts is not None else None
|
||||
color = None
|
||||
if run.font.color is not None and run.font.color.rgb is not None:
|
||||
color = str(run.font.color.rgb)
|
||||
return {
|
||||
"name": run.font.name,
|
||||
"eastAsia": east_asia,
|
||||
"size": _safe_pt(run.font.size),
|
||||
"bold": bool(run.bold) if run.bold is not None else False,
|
||||
"italic": bool(run.italic) if run.italic is not None else False,
|
||||
"color": color or "000000",
|
||||
}
|
||||
return {
|
||||
"name": None,
|
||||
"eastAsia": None,
|
||||
"size": None,
|
||||
"bold": False,
|
||||
"italic": False,
|
||||
"color": "000000",
|
||||
}
|
||||
|
||||
|
||||
def _capture_paragraph_style(paragraph: Paragraph, level: int) -> dict:
|
||||
fmt = paragraph.paragraph_format
|
||||
return {
|
||||
"font": _get_run_font_info(paragraph),
|
||||
"paragraph": {
|
||||
"alignment": _alignment_name(paragraph.alignment),
|
||||
"spaceBefore": _safe_pt(fmt.space_before),
|
||||
"spaceAfter": _safe_pt(fmt.space_after),
|
||||
"lineSpacing": fmt.line_spacing,
|
||||
"firstLineIndent": _safe_indent(fmt.first_line_indent),
|
||||
},
|
||||
"headingLevel": level,
|
||||
}
|
||||
|
||||
|
||||
def _get_cell_style(cell) -> dict:
|
||||
paragraph = cell.paragraphs[0] if cell.paragraphs else None
|
||||
font_info = _get_run_font_info(paragraph) if paragraph is not None else {
|
||||
"name": None,
|
||||
"eastAsia": None,
|
||||
"size": None,
|
||||
"bold": False,
|
||||
"italic": False,
|
||||
"color": "000000",
|
||||
}
|
||||
return {
|
||||
"font": font_info,
|
||||
"shading": None,
|
||||
"alignment": _alignment_name(paragraph.alignment) if paragraph is not None else "LEFT",
|
||||
"borders": {"top": None, "bottom": None, "left": None, "right": None},
|
||||
}
|
||||
|
||||
|
||||
def _extract_table_data(table: Table) -> dict:
|
||||
rows = len(table.rows)
|
||||
cols = max((len(row.cells) for row in table.rows), default=0)
|
||||
grid_span: dict[str, int] = {}
|
||||
cell_styles: list[dict] = []
|
||||
matrix: list[list[str]] = []
|
||||
|
||||
for row_index, row in enumerate(table.rows):
|
||||
row_values: list[str] = []
|
||||
for col_index, cell in enumerate(row.cells):
|
||||
text = "\n".join(paragraph.text.strip() for paragraph in cell.paragraphs if paragraph.text.strip())
|
||||
row_values.append(text)
|
||||
tc_pr = cell._tc.tcPr
|
||||
grid_span_value = None
|
||||
if tc_pr is not None and tc_pr.gridSpan is not None:
|
||||
grid_span_value = tc_pr.gridSpan.val
|
||||
if grid_span_value:
|
||||
grid_span[f"{row_index}-{col_index}"] = int(grid_span_value)
|
||||
cell_styles.append(_get_cell_style(cell))
|
||||
matrix.append(row_values)
|
||||
|
||||
return {
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
"gridSpan": grid_span,
|
||||
"cellStyles": cell_styles,
|
||||
"tableWidth": None,
|
||||
"data": matrix,
|
||||
}
|
||||
|
||||
|
||||
def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||
document = Document(file_path)
|
||||
parsed: list[ParsedParagraph] = []
|
||||
current_item: ParsedParagraph | None = None
|
||||
loose_table_count = 0
|
||||
|
||||
for block in _iter_block_items(document):
|
||||
if isinstance(block, Paragraph):
|
||||
text = block.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
level = _heading_level(block.style.name if block.style is not None else "")
|
||||
if level is not None:
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
title=text,
|
||||
content="",
|
||||
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
|
||||
is_table=False,
|
||||
table_json="{}",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
continue
|
||||
|
||||
if current_item is None:
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
title="未命名段落",
|
||||
content=text,
|
||||
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
||||
is_table=False,
|
||||
table_json="{}",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
else:
|
||||
current_item.content = "\n".join(filter(None, [current_item.content, text]))
|
||||
else:
|
||||
table_data = _extract_table_data(block)
|
||||
table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列"
|
||||
if current_item is None:
|
||||
loose_table_count += 1
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
title=f"表格_{loose_table_count}",
|
||||
content=table_text,
|
||||
style_json="{}",
|
||||
is_table=True,
|
||||
table_json=json.dumps(table_data, ensure_ascii=False),
|
||||
)
|
||||
parsed.append(current_item)
|
||||
else:
|
||||
current_item.is_table = True
|
||||
current_item.table_json = json.dumps(table_data, ensure_ascii=False)
|
||||
current_item.content = "\n".join(filter(None, [current_item.content, table_text]))
|
||||
|
||||
return parsed
|
||||
@@ -0,0 +1,14 @@
|
||||
# 任务执行摘要
|
||||
|
||||
## 会话 ID: local-20260702145410
|
||||
- [2026-07-02 14:54:10]
|
||||
- **执行原因**: 按任务拆解清单逐项推进,优先打通模板管理与模板解析的第一条可用链路,并同步回填已完成状态。
|
||||
- **执行过程**:
|
||||
1. 核对任务拆解清单、开发规范和模板格式规范,确认项目约束与当前仓库状态。
|
||||
2. 检查前后端现状,识别出前端脚手架、数据库模型和多页面壳子已存在,但后端核心路由仍为空。
|
||||
3. 新增模板解析服务,基于 `python-docx` 实现标题识别、正文归并、样式提取、表格基础结构提取和结构化输出。
|
||||
4. 实现模板 CRUD 路由,补齐模板列表、详情、上传解析、段落配置保存和删除能力,并接入 MinIO 模板存储。
|
||||
5. 为后端补充统一错误响应格式与空路由占位,避免应用启动时报错。
|
||||
6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。
|
||||
7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。
|
||||
- **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。
|
||||
+14
-14
@@ -4,24 +4,24 @@
|
||||
|
||||
## 第一阶段:架构与规范(第 1 周)
|
||||
|
||||
- [ ] 搭建前端脚手架(Vue 3 + Vite + TS + Ant Design Vue + Pinia + Router)
|
||||
- [ ] 搭建后端脚手架(FastAPI + SQLAlchemy async + SQLite)
|
||||
- [ ] 数据库表设计与建表
|
||||
- [ ] 模板格式规范定稿(段落边界规则、标题样式要求、表格归属)
|
||||
- [ ] AI 输出格式规范定稿(JSON 结构、表格标记、错误兜底)
|
||||
- [ ] 导出策略定稿(基于原模板替换内容)
|
||||
- [ ] 前后端 API 接口约定
|
||||
- [x] 搭建前端脚手架(Vue 3 + Vite + TS + Ant Design Vue + Pinia + Router)
|
||||
- [x] 搭建后端脚手架(FastAPI + SQLAlchemy async + SQLite)
|
||||
- [x] 数据库表设计与建表
|
||||
- [x] 模板格式规范定稿(段落边界规则、标题样式要求、表格归属)
|
||||
- [x] AI 输出格式规范定稿(JSON 结构、表格标记、错误兜底)
|
||||
- [x] 导出策略定稿(基于原模板替换内容)
|
||||
- [x] 前后端 API 接口约定
|
||||
|
||||
## 第二阶段:后端核心开发(第 2-3 周)
|
||||
|
||||
### Word 解析器(5-7 天)
|
||||
- [ ] python-docx 打开模板,逐段落遍历
|
||||
- [ ] 标题样式识别(Heading 1~6)→ 段落边界
|
||||
- [ ] 正文内容捕获 → 合并到上一标题
|
||||
- [x] python-docx 打开模板,逐段落遍历
|
||||
- [x] 标题样式识别(Heading 1~6)→ 段落边界
|
||||
- [x] 正文内容捕获 → 合并到上一标题
|
||||
- [ ] 表格结构提取(行列数、合并单元格、边框、底纹)
|
||||
- [ ] 样式捕获(字体名、字号、加粗、颜色、对齐、缩进、间距、行距)
|
||||
- [ ] 段落索引记录(在文档中的位置,用于导出定位)
|
||||
- [ ] 输出结构化 JSON
|
||||
- [x] 样式捕获(字体名、字号、加粗、颜色、对齐、缩进、间距、行距)
|
||||
- [x] 段落索引记录(在文档中的位置,用于导出定位)
|
||||
- [x] 输出结构化 JSON
|
||||
|
||||
### AI 服务层(5-7 天)
|
||||
- [ ] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
|
||||
@@ -48,7 +48,7 @@
|
||||
- [ ] PDF 导出(调用 LibreOffice 命令)
|
||||
|
||||
### 路由与 API(3 天)
|
||||
- [ ] 模板 CRUD 路由
|
||||
- [x] 模板 CRUD 路由
|
||||
- [ ] 模型 CRUD 路由
|
||||
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
|
||||
- [ ] 导出路由(Word/PDF)
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
<div class="logo">{{ collapsed ? "A" : "AI 文档模板" }}</div>
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="onMenuClick">
|
||||
<a-menu-item key="/templates"><folder-outlined /> 模板管理</a-menu-item>
|
||||
<a-menu-item key="/models"><cpu-outlined /> 模型管理</a-menu-item>
|
||||
<a-menu-item key="/models"><api-outlined /> 模型管理</a-menu-item>
|
||||
<a-menu-item key="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
|
||||
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item>
|
||||
</a-menu>
|
||||
@@ -30,7 +30,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
FolderOutlined, CpuOutlined, ThunderboltOutlined, ClockCircleOutlined,
|
||||
FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined,
|
||||
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template><a-upload-dragger :multiple="multiple" :beforeUpload="(f)=>{$emit('upload',f);return false}" :showUploadList="showList"><p class="ant-upload-drag-icon"><upload-outlined /></p><p class="ant-upload-text">{{text}}</p><p class="ant-upload-hint">{{hint}}</p></a-upload-dragger></template>
|
||||
<template><a-upload-dragger :multiple="multiple" :beforeUpload="(f: File)=>{$emit('upload',f);return false}" :showUploadList="showList"><p class="ant-upload-drag-icon"><upload-outlined /></p><p class="ant-upload-text">{{text}}</p><p class="ant-upload-hint">{{hint}}</p></a-upload-dragger></template>
|
||||
<script setup lang="ts">
|
||||
import { UploadOutlined } from "@ant-design/icons-vue";
|
||||
defineProps<{text?:string;hint?:string;multiple?:boolean;showList?:boolean}>()
|
||||
defineEmits<{upload:[file:File]}>()
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template><a-modal v-model:open="visible" title="段落测试" :footer="null" width="680px"><a-steps :current="step" style="margin-bottom:24px"><a-step title="上传附件" /><a-step title="处理中" /><a-step title="查看结果" /></a-steps><div v-if="step===0"><a-upload-dragger :beforeUpload="(f)=>{uploadFile=f;step=1;return false}"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击上传参考文件</p></a-upload-dragger></div><div v-if="step===1" style="text-align:center;padding:40px"><a-spin size="large" /><p style="margin-top:16px;color:#666">正在解析文件 → 请求 AI 模型...</p></div><div v-if="step===2"><div style="background:#f0f0ff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px"><p>AI 生成结果预览:</p><p>{{result}}</p></div></div></a-modal></template>
|
||||
<template><a-modal v-model:open="visible" title="段落测试" :footer="null" width="680px"><a-steps :current="step" style="margin-bottom:24px"><a-step title="上传附件" /><a-step title="处理中" /><a-step title="查看结果" /></a-steps><div v-if="step===0"><a-upload-dragger :beforeUpload="(f: File)=>{uploadFile=f;step=1;return false}"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击上传参考文件</p></a-upload-dragger></div><div v-if="step===1" style="text-align:center;padding:40px"><a-spin size="large" /><p style="margin-top:16px;color:#666">正在解析文件 → 请求 AI 模型...</p></div><div v-if="step===2"><div style="background:#f0f0ff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px"><p>AI 生成结果预览:</p><p>{{result}}</p></div></div></a-modal></template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { FileAddOutlined } from "@ant-design/icons-vue";
|
||||
@@ -10,4 +10,4 @@ const uploadFile = ref<File|null>(null)
|
||||
const result = ref("这是 AI 生成的示例内容...")
|
||||
watch(()=>props.open,v=>{visible.value=v;if(v){step.value=0;uploadFile.value=null}})
|
||||
watch(visible,v=>{if(!v)emit('close')})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
|
||||
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f: File)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -23,4 +23,4 @@ async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);para
|
||||
function handleFileUpload(paraId:number,file:File){uploadedFiles.value[paraId]=file.name;return false}
|
||||
async function startGen(){if(!selectedTplId.value){message.warning("请先选择模板");return}generating.value=true;progress.value=0;progressText.value="正在生成...";const data={template_id:selectedTplId.value,file_map:{}};try{const doc=await docStore.generateFull(data);message.success("生成完成");router.push(`/preview/${doc.id}`)}catch(e:any){message.error(e.message||"生成失败")}finally{generating.value=false}}
|
||||
</script>
|
||||
<style scoped>.para-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid #f0f0f0;border-radius:8px;margin-bottom:8px}.para-row.needFile{background:#fff;border-color:#d9d9d9}.para-row.noFile{background:#fafafa;border-style:dashed;opacity:.7}.para-info{display:flex;align-items:center;gap:8px}.para-info .idx{width:22px;height:22px;border-radius:50%;background:#f0f0ff;color:#5b5bd6;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.no-file-tag{font-size:12px;color:#999}</style>
|
||||
<style scoped>.para-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid #f0f0f0;border-radius:8px;margin-bottom:8px}.para-row.needFile{background:#fff;border-color:#d9d9d9}.para-row.noFile{background:#fafafa;border-style:dashed;opacity:.7}.para-info{display:flex;align-items:center;gap:8px}.para-info .idx{width:22px;height:22px;border-radius:50%;background:#f0f0ff;color:#5b5bd6;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.no-file-tag{font-size:12px;color:#999}</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template><div style="padding:24px"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h2>模型管理</h2><a-button type="primary" @click="openAdd">添加模型</a-button></div><a-row :gutter="[16,16]"><a-col :span="8" v-for="m in models" :key="m.id"><a-card :title="m.name" :extra="<a-button type='link' size='small' @click='openEdit(m)'>编辑</a-button>"><p>厂商:{{m.provider}}</p><p>格式:{{m.api_format}}</p><p>地址:{{m.api_endpoint}}</p><p>状态:<a-switch :checked="m.status==='enabled'" @change="toggleStatus(m)" /></p></a-card></a-col></a-row><a-modal v-model:open="modalOpen" :title="isEdit?'编辑模型':'添加模型'" @ok="saveModel"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI 格式</a-select-option><a-select-option value="anthropic">Anthropic 格式</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" placeholder="https://api.xxx.com" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></div></template>
|
||||
<template><div style="padding:24px"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h2>模型管理</h2><a-button type="primary" @click="openAdd">添加模型</a-button></div><a-row :gutter="[16,16]"><a-col :span="8" v-for="m in models" :key="m.id"><a-card :title="m.name"><template #extra><a-button type="link" size="small" @click="openEdit(m)">编辑</a-button></template><p>厂商:{{m.provider}}</p><p>格式:{{m.api_format}}</p><p>地址:{{m.api_endpoint}}</p><p>状态:<a-switch :checked="m.status==='enabled'" @change="toggleStatus(m)" /></p></a-card></a-col></a-row><a-modal v-model:open="modalOpen" :title="isEdit?'编辑模型':'添加模型'" @ok="saveModel"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI 格式</a-select-option><a-select-option value="anthropic">Anthropic 格式</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" placeholder="https://api.xxx.com" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></div></template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useModelStore } from "@/stores/model";
|
||||
@@ -14,4 +14,4 @@ function openAdd(){isEdit.value=false;form.value={name:"",provider:"",api_format
|
||||
function openEdit(m:any){isEdit.value=true;editId.value=m.id;form.value={name:m.name,provider:m.provider,api_format:m.api_format,api_endpoint:m.api_endpoint,api_key:""};modalOpen.value=true}
|
||||
async function saveModel(){if(isEdit.value){await store.update(editId.value,form.value)}else{await store.create(form.value)}modalOpen.value=false;models.value=store.models as any;message.success("保存成功")}
|
||||
async function toggleStatus(m:any){m.status=m.status==="enabled"?"disabled":"enabled";await store.update(m.id,{status:m.status});message.success("状态已更新")}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template><div style="padding:24px"><a-card><template #title><span style="display:flex;align-items:center;gap:8px"><file-text-outlined /> 文档编辑</span></template><template #extra><a-button-group><a-button @click="undo"><undo-outlined />撤销</a-button><a-button @click="redo"><redo-outlined />重做</a-button></a-button-group><a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button><a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button></template><div style="display:flex;gap:16px"><div style="flex:1;min-width:0"><div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap"><a-button size="small"><b>B</b></a-button><a-button size="small"><i>I</i></a-button><a-button size="small"><u>U</u></a-button><a-divider type="vertical" /><a-button size="small"><heading-outlined /></a-button><a-button size="small"><ordered-list-outlined /></a-button><a-button size="small"><table-outlined /></a-button></div><div ref="editorRef" contenteditable="true" style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8" v-html="editorHtml" @input="onEdit"></div></div><div style="width:200px;flex-shrink:0"><a-card title="文档结构" size="small"><div v-for="s in structure" :key="s.id" :class="['struct-item',{active:s.active}]" @click="scrollTo(s.id)"><file-text-outlined /> {{s.title}}</div></a-card></div></div></a-card></div></template>
|
||||
<template><div style="padding:24px"><a-card><template #title><span style="display:flex;align-items:center;gap:8px"><file-text-outlined /> 文档编辑</span></template><template #extra><a-button-group><a-button @click="undo"><undo-outlined />撤销</a-button><a-button @click="redo"><redo-outlined />重做</a-button></a-button-group><a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button><a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button></template><div style="display:flex;gap:16px"><div style="flex:1;min-width:0"><div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap"><a-button size="small"><b>B</b></a-button><a-button size="small"><i>I</i></a-button><a-button size="small"><u>U</u></a-button><a-divider type="vertical" /><a-button size="small"><font-size-outlined /></a-button><a-button size="small"><ordered-list-outlined /></a-button><a-button size="small"><table-outlined /></a-button></div><div ref="editorRef" contenteditable="true" style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8" v-html="editorHtml" @input="onEdit"></div></div><div style="width:200px;flex-shrink:0"><a-card title="文档结构" size="small"><div v-for="s in structure" :key="s.id" :class="['struct-item',{active:s.active}]" @click="scrollTo(s.id)"><file-text-outlined /> {{s.title}}</div></a-card></div></div></a-card></div></template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { generateApi } from "@/api/generate";
|
||||
import { message } from "ant-design-vue";
|
||||
import { FileTextOutlined, UndoOutlined, RedoOutlined, HeadingOutlined, OrderedListOutlined, TableOutlined } from "@ant-design/icons-vue";
|
||||
import { FileTextOutlined, UndoOutlined, RedoOutlined, FontSizeOutlined, OrderedListOutlined, TableOutlined } from "@ant-design/icons-vue";
|
||||
const route = useRoute();
|
||||
const editorRef = ref<HTMLElement|null>(null);
|
||||
const editorHtml = ref(`<h2 style="text-align:center">2025年第一季度经济活动分析报告</h2><p style="text-align:center;color:#666">某某集团有限公司</p><h3>一、主要经营指标完成情况</h3><div style="background:#f0f0ff;padding:12px;border-left:3px solid #5b5bd6;border-radius:4px;margin:8px 0"><p>本季度营业收入完成<strong>12.35亿元</strong>,同比上升<strong>8.7%</strong>。</p></div><h3>主要经营指标完成情况表</h3><table border="1" style="width:100%;border-collapse:collapse"><tr><th>指标</th><th>完成值</th><th>同比</th></tr><tr><td>营业收入</td><td>12.35亿</td><td>+8.7%</td></tr></table><h3>二、成本费用分析</h3><p>本季度总成本9.87亿元,同比上升6.2%。</p>`);
|
||||
@@ -16,4 +16,4 @@ function redo(){document.execCommand('redo')}
|
||||
function exportDocx(){const id=route.params.id;window.open(generateApi.exportDocx(Number(id)))}
|
||||
function exportPdf(){const id=route.params.id;window.open(generateApi.exportPdf(Number(id)))}
|
||||
</script>
|
||||
<style scoped>.struct-item{padding:8px;cursor:pointer;border-radius:4px;font-size:13px}.struct-item:hover{background:#f5f5f5}.struct-item.active{background:#f0f0ff;color:#5b5bd6}</style>
|
||||
<style scoped>.struct-item{padding:8px;cursor:pointer;border-radius:4px;font-size:13px}.struct-item:hover{background:#f5f5f5}.struct-item.active{background:#f0f0ff;color:#5b5bd6}</style>
|
||||
|
||||
Reference in New Issue
Block a user