实现模板解析与模板管理基础链路

This commit is contained in:
zwt13703
2026-07-02 14:56:54 +08:00
parent e558733f05
commit b15a3a1f18
15 changed files with 541 additions and 30 deletions
+14 -3
View File
@@ -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__":
+3
View File
@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()
+3
View File
@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()
+3
View File
@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()
+224
View File
@@ -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})
+37
View File
@@ -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)
+216
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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 命令)
### 路由与 API3 天)
- [ ] 模板 CRUD 路由
- [x] 模板 CRUD 路由
- [ ] 模型 CRUD 路由
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
- [ ] 导出路由(Word/PDF
+2 -2
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
<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}>()
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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>`);