This commit is contained in:
zwt13703
2026-07-02 14:48:05 +08:00
parent a1a314cf99
commit e558733f05
64 changed files with 3258 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="azul-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/doc-forge.iml" filepath="$PROJECT_DIR$/.idea/doc-forge.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+58
View File
@@ -0,0 +1,58 @@
# AGENTS.md — doc-forge
你正在参与一个外包项目,客户需要一套完整的 AI 文档模板生成系统。
## 你的角色
全栈开发 AI 助手,负责生成 Vue 3 + Ant Design Vue 前端代码和 Python FastAPI 后端代码。
## 基础设施
### MySQL
- 数据库名:`doc_forge`,字符集 `utf8mb4`
- 异步驱动:`asyncmy`
- 连接池:pool_size=10, max_overflow=20
- 本地开发:`docker-compose up mysql`
- DDL 见 `init.sql`
### MinIO(对象存储)
- 三个 bucket`doc-forge-templates`(模板)/ `doc-forge-uploads`(参考文件)/ `doc-forge-outputs`(导出文档)
- 文件路径规则:`{bucket}/{YYYYMMDD}/{uuid}.{ext}`
- 预签名 URL 用于前端下载,过期 1 小时
- 本地开发:`docker-compose up minio`Console http://localhost:9001
- SDK`from minio import Minio`,客户端在 `services/minio_client.py`
### Docker
- `docker-compose up -d mysql minio` 启动开发依赖
- `docker-compose up backend web` 启动全栈
## 通讯协议
- 所有 API 响应格式:`{ code: 0, data: {...}, message: "ok" }`
- 错误响应:`{ code: -1, message: "错误描述" }`
- 分页响应:`{ code: 0, data: { items: [], total: N, page: 1, page_size: 20 } }`
## 必须遵守的规则
1. **AI 输出格式** — AI 必须返回 JSON,不得返回纯文本。前端解析 `content` 数组,按 type 分段渲染。
2. **Word 导出** — 严禁重新生成文档。从 MinIO 拉取原始模板,只替换对应位置的文本节点。
3. **段落边界** — 只认 Word 标题样式(Heading)。不要尝试用正则或关键词判断段落。
4. **API Key 安全** — 所有 API Key 用 `cryptography.fernet.Fernet` 加密存储,前端只展示脱敏字符串。
5. **并发控制** — 段落生成使用 `asyncio.gather` + `Semaphore`,单文档最大并发 5。
6. **文件存储** — 所有用户文件存 MinIO,后端本地只做临时缓存。
7. docs/规范与约束/开发规范.md
8. docs/需求与设计/02-模板格式规范.md
## 段落配置字段
每个 paragraph 包含:
- `edit_mode`: 'manual' | 'ai'
- `model_id`: int | nullnull 表示使用系统默认模型)
- `need_prompt`: boolean + `prompt_text`: string
- `need_file`: boolean + `file_note`: string(备注提示上传什么文件)
- `output_format`: 'text' | 'table' | 'mixed' | 'chart'
## 容易踩的坑
- python-docx 中文字体名在 `run.fonts.eastAsia`,不是 `run.fonts.name`
- Ant Design Vue 4.x 的 modal 使用 `v-model:open`,不是 `v-model:visible`
- SSE 事件流要用 `sse-starlette``EventSourceResponse`
- asyncio 中不能混用同步的 openpyxl,Excel 解析放在线程池执行 (`run_in_executor`)
- MinIO SDK 是同步的,用 `run_in_executor` 包装,不要直接 in asyncio
- asyncmy 连接 MySQL 需要 `charset=utf8mb4`,不然中文会乱码
+67
View File
@@ -0,0 +1,67 @@
# doc-forge — AI 文档模板生成系统
## 项目概述
上传 Word 模板 → AI 自动解析段落(按标题样式切割)→ 用户标注段落配置 → 上传参考文件 → AI 多段落并行生成 → 预览编辑 → 导出 Word(保留原始样式)。
## 技术栈
| 层 | 技术 | 说明 |
|----|------|------|
| 前端 | Vue 3.4 + TypeScript + Ant Design Vue 4.x + Pinia + Vite 5 | web/ |
| 后端 | Python 3.11+ + FastAPI + SQLAlchemy 2.0 async | backend/app/ |
| 数据库 | MySQL 8.0asyncmy 驱动) | docker-compose mysql |
| 对象存储 | MinIO | 存模板文件、参考文件、生成文档 |
| Word 处理 | python-docx | 解析/导出 |
| Excel 处理 | openpyxl | 解析参考文件 |
## 目录结构
```
doc-forge/
├── web/ 前端
│ └── src/
│ ├── views/ TemplateList, TemplateEditor, ModelManage, GeneratePage, HistoryPage, PreviewEdit
│ ├── components/ ParagraphList, ParagraphConfig, DocPreview, FileUploader, ModelModal, TestModal
│ ├── api/ Axiostemplate, model, generate
│ ├── stores/ Piniatemplate, model, document
│ └── router/ 6 条路由
├── backend/
│ ├── app/
│ │ ├── models/ ORMtemplate, paragraph, ai_model, document, generation_log
│ │ ├── routers/ APItemplates, models, generate, export
│ │ ├── schemas/ Pydantic 校验
│ │ ├── services/ 业务逻辑(parser, ai_service, generator, exporter, minio_client
│ │ └── main.py
│ ├── config.py 配置(MySQL + MinIO + AI
│ └── database.py 异步引擎
├── docs/ 项目文档
├── docker-compose.yml MySQL + MinIO + backend + web
├── init.sql 数据库建表 DDL
├── CLAUDE.md
└── AGENTS.md
```
## 关键约定
### 段落解析规则
- 段落边界由 Word 标题样式(Heading 1~6)确定
- 标题与下一标题之间的正文、表格归属到该标题段落
- 表格独立存储为 `is_table=True`,归属于前一个标题
### AI 输出格式
AI 必须返回结构化 JSON
```json
{"content": [{"type": "text", "text": "..."}, {"type": "table", "headers": [], "rows": []}]}
```
### 文件存储
- 所有文件存 MinIO,不存本地磁盘
- bucket 分三类:templates / uploads / outputs
- MinIO 开发环境在 docker-compose 中启动
### AI 模型调用
- OpenAI 格式:GPT-4o, DeepSeek-V3, 通义千问
- Anthropic 格式:Claude 3.5 Sonnet
- 超时 60s,最多重试 3 次,单文档最大并发 5
### Word 导出
- 从 MinIO 拉取原始模板 → 在内存中修改 → 上传回 MinIO
- 样式完全保留(字体/颜色/行距/页边距/页眉页脚)
+78 -1
View File
@@ -1,3 +1,80 @@
# doc-forge
《文档锻造》
上传 Word 模板 → AI 逐段落生成 → 导出保留原始样式的 Word 文档。
## 技术栈
- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios
- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 async
- **数据库**: MySQL 8.0asyncmy 驱动)
- **对象存储**: MinIO(模板/参考文件/导出文档)
- **文档处理**: python-docx、openpyxl
## 快速开始
```bash
# 1. 启动基础设施(MySQL + MinIO
docker-compose up -d mysql minio
# 2. 启动后端
cd backend
pip install -r requirements.txt
python main.py
# → http://localhost:8000/docs Swagger
# 3. 启动前端
cd web
pnpm install
pnpm dev
# → http://localhost:5173
```
## 目录结构
```
doc-forge/
├── web/ Vue 3 前端
│ └── src/
│ ├── views/ 6 个页面
│ ├── components/ 6 个通用组件
│ ├── api/ Axios 请求层
│ ├── stores/ Pinia 状态管理
│ └── router/ 路由配置
├── backend/ Python FastAPI 后端
│ ├── app/models/ ORM 数据模型
│ ├── app/routers/ API 路由
│ ├── app/schemas/ Pydantic 校验
│ ├── app/services/ 业务逻辑层
│ ├── config.py 配置
│ └── database.py 异步数据库引擎
├── docker-compose.yml MySQL + MinIO + 后端 + 前端
├── init.sql 数据库建表 DDL
└── docs/ 项目文档
```
## 核心流程
```
上传模板 → 解析段落 → 标注配置 → 保存模板
→ 执行生成(上传文件 → AI 并行生成 → SSE 进度)
→ 预览编辑 → 导出 Word(保留原始样式)
```
## 关键设计
| 决策 | 选择 | 原因 |
|------|------|------|
| 段落边界 | Word 标题样式 Heading 1~6 | 稳定可靠,用户学习成本低 |
| AI 输出 | 结构化 JSON | 支持文字+表格混合,后端可控解析 |
| 导出策略 | 基于原模板替换内容 | 样式零损失,不限模板格式 |
| 文件存储 | MinIO 对象存储 | 可扩展,不占用本地磁盘 |
| 数据库 | MySQL 8.0 | 生产级可靠性 |
| 生成方式 | 多段落并行 asyncio.gather | 大幅缩短等待时间 |
## 环境要求
- Python 3.11+
- Node.js 18+
- pnpm 8+
- Docker + docker-composeMySQL + MinIO
- LibreOffice(可选,用于 PDF 导出)
+59
View File
@@ -0,0 +1,59 @@
from pydantic_settings import BaseSettings
from pathlib import Path
import os
class Settings(BaseSettings):
# 应用
APP_NAME: str = "AI 文档模板生成系统"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
# === 数据库 MySQL ===
DB_HOST: str = "localhost"
DB_PORT: int = 3306
DB_USER: str = "docforge"
DB_PASSWORD: str = "docforge123"
DB_NAME: str = "doc_forge"
@property
def DATABASE_URL(self) -> str:
return f"mysql+asyncmy://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
# === MinIO 文件存储 ===
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "docforge"
MINIO_SECRET_KEY: str = "docforge123"
MINIO_BUCKET_TEMPLATES: str = "doc-forge-templates"
MINIO_BUCKET_UPLOADS: str = "doc-forge-uploads"
MINIO_BUCKET_OUTPUTS: str = "doc-forge-outputs"
MINIO_USE_SSL: bool = False
# 本地缓存目录(MinIO 文件的本地临时缓存)
LOCAL_CACHE_DIR: str = "local_cache"
# 文件上传限制
MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024 # 50MB
ALLOWED_EXTENSIONS: list = [".docx", ".xlsx", ".xls", ".csv", ".pdf", ".txt", ".md"]
# 加密(用于 API Key 加密)
ENCRYPTION_KEY: str = "change-this-to-a-32-byte-key-in-production!!"
# AI 模型默认配置
AI_REQUEST_TIMEOUT: int = 60
AI_MAX_RETRIES: int = 3
AI_MAX_CONCURRENT: int = 5
AI_GLOBAL_CONCURRENT: int = 10
# 服务端口
HOST: str = "0.0.0.0"
PORT: int = 8000
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
# 创建本地缓存目录
os.makedirs(settings.LOCAL_CACHE_DIR, exist_ok=True)
+28
View File
@@ -0,0 +1,28 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG, pool_size=10, max_overflow=20)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
try:
yield session
finally:
await session.close()
async def init_db():
from models.template import Template
from models.paragraph import Paragraph
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+46
View File
@@ -0,0 +1,46 @@
import uvicorn
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import init_db, engine
from config import settings
from routers import templates, models, generate, export
from services.minio_client import init_buckets
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
await init_buckets() # 初始化 MinIO 存储桶
yield
await engine.dispose()
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(templates.router, prefix="/api/v1/templates", tags=["模板管理"])
app.include_router(models.router, prefix="/api/v1/models", tags=["模型管理"])
app.include_router(generate.router, prefix="/api/v1/generate", tags=["生成管理"])
app.include_router(export.router, prefix="/api/v1/export", tags=["导出管理"])
@app.get("/")
async def root():
return {"message": "AI 文档模板生成系统 API", "version": settings.APP_VERSION}
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
uvicorn.run("main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG)
+5
View File
@@ -0,0 +1,5 @@
from models.template import Template
from models.paragraph import Paragraph
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from database import Base
class AiModel(Base):
__tablename__ = "ai_models"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, comment="模型名称")
provider = Column(String(100), default="", comment="供应厂商")
api_format = Column(String(20), default="openai", comment="anthropic/openai")
api_endpoint = Column(String(500), default="", comment="API接口地址")
api_key_encrypted = Column(Text, default="", comment="加密后的API Key")
status = Column(String(20), default="enabled", comment="enabled/disabled")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from database import Base
class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
name = Column(String(255), default="", comment="文档名称")
para_count_done = Column(Integer, default=0, comment="已完成段落数")
para_count_total = Column(Integer, default=0, comment="总段落数")
status = Column(String(20), default="pending", comment="pending/generating/completed/failed/cancelled")
file_path = Column(String(500), default="", comment="生成的文件路径")
error = Column(Text, default="", comment="错误信息")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey, func
from database import Base
class GenerationLog(Base):
__tablename__ = "generation_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=False)
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True)
status = Column(String(20), default="pending", comment="pending/generating/success/failed")
content = Column(Text, default="", comment="生成的内容")
duration = Column(Float, default=0, comment="耗时秒数")
error_msg = Column(Text, default="", comment="错误信息")
created_at = Column(DateTime, server_default=func.now())
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func
from database import Base
class Paragraph(Base):
__tablename__ = "paragraphs"
id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
sort_index = Column(Integer, default=0, comment="排序")
title = Column(String(500), default="", comment="段落标题")
content = Column(Text, default="", comment="正文内容/上下文")
style_json = Column(Text, default="{}", comment="段落样式定义JSON")
is_table = Column(Boolean, default=False, comment="是否为表格")
table_json = Column(Text, default="{}", comment="表格结构JSON")
edit_mode = Column(String(20), default="ai", comment="manual/ai")
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
prompt_text = Column(Text, default="", comment="预设提示词")
need_file = Column(Boolean, default=False, comment="是否需要上传参考文件")
file_note = Column(Text, default="", comment="备注说明(传什么文件)")
output_format = Column(String(20), default="text", comment="text/table/mixed/chart")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from database import Base
class Template(Base):
__tablename__ = "templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, comment="模板名称")
description = Column(Text, default="", comment="描述")
file_path = Column(String(500), nullable=False, comment="原始模板文件路径")
paragraph_count = Column(Integer, default=0, comment="段落数")
status = Column(String(20), default="draft", comment="draft/ready")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+17
View File
@@ -0,0 +1,17 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
sqlalchemy>=2.0.25
asyncmy>=0.2.9 # MySQL async driver
aiomysql>=0.2.0 # MySQL async fallback
cryptography>=42.0.0
python-docx>=1.1.0
openpyxl>=3.1.0
pandas>=2.1.0
httpx>=0.27.0
pydantic>=2.5.0
pydantic-settings>=2.1.0
python-multipart>=0.0.6
aiofiles>=23.2.0
sse-starlette>=2.0.0
minio>=7.2.0 # MinIO 对象存储 SDK
alembic>=1.13.0
View File
View File
View File
View File
View File
View File
+86
View File
@@ -0,0 +1,86 @@
from pydantic import BaseModel, Field
from typing import Optional, Any
from datetime import datetime
class Response(BaseModel):
code: int = 0
data: Any = None
message: str = "ok"
class PageData(BaseModel):
items: list = []
total: int = 0
page: int = 1
page_size: int = 20
# 模板
class TemplateCreate(BaseModel):
name: str
description: str = ""
class TemplateOut(BaseModel):
id: int
name: str
description: str = ""
file_path: str = ""
paragraph_count: int = 0
status: str = "draft"
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class ParagraphConfig(BaseModel):
id: int = 0
sort_index: int = 0
title: str = ""
edit_mode: str = "ai"
model_id: Optional[int] = None
need_prompt: bool = True
prompt_text: str = ""
need_file: bool = False
file_note: str = ""
output_format: str = "text"
class TemplateSave(BaseModel):
paragraphs: list[ParagraphConfig] = []
# 模型
class AiModelCreate(BaseModel):
name: str
provider: str = ""
api_format: str = "openai"
api_endpoint: str = ""
api_key: str = ""
status: str = "enabled"
class AiModelOut(BaseModel):
id: int
name: str
provider: str = ""
api_format: str = "openai"
api_endpoint: str = ""
api_key_preview: str = ""
status: str = "enabled"
created_at: Optional[datetime] = None
# 生成
class GenerateTestRequest(BaseModel):
paragraph_id: int
template_id: int
prompt_text: str = ""
model_id: int = 0
file_paths: list[str] = []
class GenerateFullRequest(BaseModel):
template_id: int
file_map: dict[str, str] = {} # paragraph_id -> file_path
class DocumentOut(BaseModel):
id: int
template_id: int
name: str
para_count_done: int = 0
para_count_total: int = 0
status: str = "pending"
file_path: str = ""
error: str = ""
created_at: Optional[datetime] = None
View File
+80
View File
@@ -0,0 +1,80 @@
version: "3.8"
services:
# === MySQL ===
mysql:
image: mysql:8.0
container_name: doc-forge-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: doc_forge
MYSQL_USER: docforge
MYSQL_PASSWORD: docforge123
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
# === MinIO(对象存储)===
minio:
image: minio/minio:latest
container_name: doc-forge-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: docforge
MINIO_ROOT_PASSWORD: docforge123
ports:
- "9000:9000" # API
- "9001:9001" # Console
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 3
# === 后端 ===
backend:
build: ./backend
container_name: doc-forge-backend
restart: unless-stopped
depends_on:
mysql:
condition: service_started
minio:
condition: service_healthy
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_USER: docforge
DB_PASSWORD: docforge123
DB_NAME: doc_forge
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: docforge
MINIO_SECRET_KEY: docforge123
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
ports:
- "8000:8000"
volumes:
- ./backend:/app
- local_cache:/app/local_cache
# === 前端 ===
web:
build: ./web
container_name: doc-forge-web
restart: unless-stopped
depends_on:
- backend
ports:
- "5173:80"
volumes:
mysql_data:
minio_data:
local_cache:
+158
View File
@@ -0,0 +1,158 @@
# AI 提示词库
本文档包含所有 AI 调用时使用的提示词模板。提示词分为系统级和段落级两层。
## 一、系统提示词
### 1.1 默认系统提示词(通用)
```
你是一个专业的企业文档撰写助手。你的任务是按照给定的段落标题和参考内容,
生成符合中文正式报告风格的段落内容。
要求:
1. 语言正式、客观、严谨,使用第三人称
2. 逻辑清晰,层次分明
3. 数据准确,引用上传文件中的实际数据
4. 字数控制在 300-800 字之间
5. 不要输出标题本身,只输出段落正文内容
6. 如果正文需要分点描述,使用 1. 2. 3. 编号,不要使用无序列表
输出格式必须为 JSON
{
"content": [
{"type": "text", "text": "正文内容..."}
]
}
如果需要输出表格,使用:
{
"content": [
{"type": "text", "text": "表格说明文字"},
{"type": "table", "headers": ["列1","列2","列3"], "rows": [["数据1","数据2","数据3"]]}
]
}
```
### 1.2 表格生成专用提示词
```
请根据上传的数据文件,生成以下表格内容:
段落标题:{title}
要求:
1. 表格列名清晰,数据准确
2. 只输出表格内容,不要文字说明
3. 如果有多组数据,优先合并到一张表中
输出格式:
{
"content": [
{"type": "table", "headers": ["列名1","列名2","..."], "rows": [["值1","值2","..."]]}
]
}
```
### 1.3 报告摘要专用提示词
```
请根据以下参考内容,生成一段简洁的摘要。
要求:
1. 概括核心要点,不超过 200 字
2. 突出关键数据和结论
3. 使用总分结构
输出格式:
{
"content": [
{"type": "text", "text": "摘要内容..."}
]
}
```
## 二、段落预设提示词
### 2.1 经营指标分析
```
请根据上传的财务数据,生成"{title}"章节内容。
要求包括以下方面:
1. 各核心指标的完成值
2. 与上期/同期的同比变化
3. 变化原因分析
4. 存在的主要风险点
参考上下文:
{context}
```
### 2.2 成本费用分析
```
请根据上传的数据,生成成本费用分析内容。
要求包括:
1. 各项成本的构成及占比
2. 同比变化情况及原因
3. 成本管控措施及成效
参考数据:
{context}
```
### 2.3 问题总结
```
请基于以下数据和背景,分析当前存在的主要问题和风险。
要求:
1. 问题描述要具体,有数据支撑
2. 分析问题产生的原因
3. 指出风险等级和影响范围
参考内容:
{context}
```
### 2.4 工作措施
```
请针对上述问题,生成下一步工作措施。
要求:
1. 措施具体可执行
2. 明确责任主体
3. 设定完成时限或目标值
4. 措施之间逻辑递进
参考内容:
{context}
```
## 三、提示词拼接规则
### 3.1 最终 prompt 构成
```
[系统提示词]
---
段落标题:{paragraph.title}
编辑方式:{paragraph.edit_mode}
输出格式:{paragraph.output_format}
---
{paragraph.prompt_text}
---
参考文件摘要:
{file_summary}
---
参考上下文:
{paragraph.content}
```
### 3.2 参考文件摘要生成规则
```
读取上传的 Excel 文件:
1. 提取列名 + 前 10 行数据作为样本
2. 统计数值列的和/均值/最大最小值
3. 生成文本摘要
Excel 摘要示例:
"文件:财务数据报表.xlsx
包含 3 个工作表:
- Sheet1(使用中):列 [月份, 营业收入, 利润总额, 净利润],共 12 行数据
营业收入合计:148.2 亿元,月均 12.35 亿元
利润总额合计:15.0 亿元,月均 1.25 亿元
```
+105
View File
@@ -0,0 +1,105 @@
# AI 文档模板生成系统 · 开发规范
## 一、代码规范
### 1.1 Python 后端
- Python 3.11+,使用类型注解
- 文件命名:snake_case.py
- 类命名:PascalCase
- 函数/变量:snake_case
- 数据库表:小写复数(templates, paragraphs
- 异步优先:async/await 贯穿全栈
### 1.2 TypeScript 前端
- TypeScript 5.xstrict 模式
- 文件命名:PascalCase.vue(组件),camelCase.ts(工具/API
- 组件命名:多单词 PascalCase
- 变量/函数:camelCase
- 接口命名:I 开头或 PascalCase
- 使用 `<script setup lang="ts">` 组合式 API
- 禁止使用 `any`
### 1.3 API 规范
- 基础路径:`/api/v1`
- RESTful 风格
- 请求体:JSON
- 响应格式:`{ code: 0, data: {...}, message: "ok" }`
- 分页:`{ code: 0, data: { items: [], total: N, page: 1, page_size: 20 } }`
## 二、数据库规范
### 2.1 命名
- 表名:小写复数
- 主键:id (INTEGER PRIMARY KEY AUTOINCREMENT)
- 时间戳:created_at, updated_at (DATETIME)
- 外键:{table}_id (INTEGER, REFERENCES {table}(id))
### 2.2 约束
- 所有表必须有 created_at 和 updated_at
- 软删除不实现,用 DELETE 物理删除
- 开发环境用 SQLite,生产环境可切换 PostgreSQL
## 三、安全规范
### 3.1 API Key 加密
- 使用 cryptography.fernet.Fernet 加密
- 加密密钥从环境变量 ENCRYPTION_KEY 读取
- 数据库仅存储密文
- 前端展示脱敏:前 3 位 + **** + 后 4 位
### 3.2 文件上传
- 允许类型:.docx, .xlsx, .xls, .csv, .pdf, .txt, .md
- 大小限制:单文件 ≤ 50MB
- 存储路径:uploads/{YYYYMMDD}/{uuid}.{ext}
- MIME type + 扩展名双重校验
## 四、AI 模型调用规范
### 4.1 超时
- 单次 AI 请求超时:60 秒
- 重试策略:最多 3 次,指数退避(2s → 4s → 8s)
### 4.2 并发
- 单文档最大并发:5 个段落同时请求
- 全局最大并发:10 个段落同时请求(跨文档)
- 使用 asyncio.Semaphore 控制
### 4.3 错误处理
- 400 错误:重试
- 401/403 错误:标记模型不可用,停止生成
- 429 错误:等待 30 秒后重试
- 500 错误:重试,3 次后标记段落失败
- 超时错误:重试,3 次后标记段落失败
## 五、Word 导出规范
### 5.1 导出流程
1. 复制原始模板文件(作为样式骨架)
2. 获取 template 的 file_path
3. 用 python-docx 打开副本
4. 遍历段落 → 找到对应位置 → 替换内容
5. 保存为新文件
### 5.2 段落定位策略
按优先级:
1. 段落 ID 精确匹配(解析时记录的 paragraph_id
2. 段落标题文本完全匹配
3. 段落序号匹配(一、二、三 / 1.1 / 1.2)
4. 段落索引匹配(第 N 个位置)
### 5.3 表格处理
- 导出时保留原模板中的表格占位(空的带样式表格或占位标记)
- 用 python-docx 找到表格节点,逐格填充数据
- 如需新增表格,在段落最后插入
## 六、前端组件规范
### 6.1 Ant Design Vue 使用规范
- 使用 composition API + `<script setup>`
- 组件样式使用 `<style scoped>`
- 全局覆盖 Ant Design 主题色在 App.vue 中通过 ConfigProvider 设置
### 6.2 状态管理
- 使用 Pinia,每个模块独立 store
- API 请求在 store 的 action 中调用,组件只 dispatch action
- 加载状态由 store 内部的 loading 字段管理
+40
View File
@@ -0,0 +1,40 @@
# AI 文档模板生成系统 · 项目概述
## 一句话
上传 Word 模板 → AI 自动解析段落结构 → 配置各段落生成规则 → 上传参考文件 → AI 并行生成 → 在线预览编辑 → 导出保留原始样式的 Word 文档。
## 核心架构
```
┌─────────────────────────────────────────────────────┐
│ 前端 Vue 3 + Ant Design Vue │
│ 模板管理 模型管理 执行生成 生成记录 预览编辑 │
└────────────┬───────────────────────────┬────────────┘
│ REST API + SSE 进度 │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ 后端 FastAPI + SQLAlchemy │
│ 模板解析 → 段落管理 → AI 调度 → 文档生成 → 导出 │
└─────────────────────────────────────────────────────┘
```
## 核心流程(7步闭环)
1. **上传模板** — 上传 .docx → 解析段落结构 + 完整样式 → 存库
2. **段落标注** — 三栏编辑器:左栏点段落→中栏定位→右栏配置(编辑方式/模型/提示词/参考文件)
3. **保存模板** — 段落配置持久化
4. **执行生成** — 选模板 → 按需上传参考文件 → 启动生成
5. **并行生成** — 无依赖段落同时请求 AI,SSE 实时推进度
6. **预览编辑** — 富文本编辑器查看结果,手动精修
7. **导出 Word** — 基于原模板替换内容,样式零损失
## 关键设计决策
| 决策 | 选择 | 原因 |
|------|------|------|
| 段落边界 | Word 内置标题样式 (Heading 1~6) | 最稳定,用户学习成本低 |
| 输出格式 | AI 返回结构化 JSON | 支持文字+表格混合,后端可控解析 |
| 导出策略 | 基于原模板替换内容 | 样式零损失,不限模板格式 |
| 生成方式 | 多段落并行请求 AI | 大幅缩短等待时间 |
| 样式保留 | 导出时读取原模板段落样式映射 | 精度最高 |
## 技术栈
- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios
- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 (async) + SQLite + python-docx + openpyxl + httpx
@@ -0,0 +1,182 @@
# AI 文档模板生成系统 · 需求规格说明书
## 一、功能需求
### 1.1 模板管理
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 模板列表 | 卡片式展示所有模板,含名称、段落数、状态、最后编辑时间 | P0 |
| 新建模板 | 上传 .docx 文件,自动解析段落结构 | P0 |
| 模板编辑器(三栏) | 左栏段落列表、中栏文档预览、右栏段落配置 | P0 |
| 段落标注 | 配置每个段落的编辑方式、模型、提示词、参考文件 | P0 |
| 保存模板 | 将段落配置持久化到数据库 | P0 |
### 1.2 模型管理
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 模型列表 | 卡片式展示所有 AI 模型,含启用/禁用开关 | P0 |
| 添加/编辑模型 | 弹窗表单:名称、厂商、API 格式、地址、Key(加密存储) | P0 |
| 默认模型分配 | 设置文本/表格/图表各自的默认生成模型 | P0 |
### 1.3 执行生成
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 选择模板 | 下拉选择已保存的模板 | P0 |
| 上传参考文件 | 按段落需上传的文件类型提示并上传 | P0 |
| 开始生成 | 启动多段落并行 AI 生成 | P0 |
| 实时进度 | SSE 推送每个段落的生成状态 | P0 |
| 取消生成 | 中断正在进行的生成任务 | P1 |
### 1.4 生成记录
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 统计卡片 | 总次数、成功、失败、中断 | P0 |
| 历史列表 | 卡片式展示已生成文档 | P0 |
| 筛选 | 按模板、按状态筛选 | P1 |
| 预览 | 跳转到预览编辑页 | P0 |
| 下载 | 下载已生成的 Word 文档 | P0 |
### 1.5 预览编辑
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 富文本编辑 | 在线编辑文档内容 | P0 |
| AI 内容标注 | AI 生成的段落标紫色边框+模型来源 | P0 |
| 重新生成单段落 | 对某一段落单独重新请求 AI | P0 |
| 导出 Word | 保留原始模板样式 | P0 |
| 导出 PDF | 通过 LibreOffice 转换 | P1 |
## 二、段落解析规则
### 2.1 段落边界定义
段落以 Word 内置标题样式为边界:
```
Heading 1 → 一级段落(如 "一、经营指标"
Heading 2 → 二级段落(如 "1.1 营收分析"
Heading 3 → 三级段落
无标题样式 → 合并到上一个标题下的正文内容
表格 → 独立段落,归属于前一个标题
```
### 2.2 标题下的正文内容
标题与下一个标题之间的所有正文、表格、图片:
- 作为该段落的 `content` 字段
- 供 AI 生成时作为上下文参考
- 导出时保留原样式
### 2.3 AI 返回格式约定
AI 输出必须是结构化 JSON
```json
{
"content": [
{"type": "text", "text": "正文内容..."},
{"type": "table", "headers": ["列1","列2"], "rows": [["a","b"],["c","d"]]},
{"type": "text", "text": "更多正文..."}
]
}
```
后端解析逻辑:
- type=text → 替换文档中对应段落的文本
- type=table → 在对应位置插入 Word 表格,表格样式参照该段落附近已有表格
### 2.4 正文内编号处理
AI 生成的 1、2、3 编号属于该段落的内部子结构,不拆分为新段落。导出时作为该段落的正文内容,应用该段落的样式。
## 三、导出策略
采用 **基于原模板替换内容** 策略:
1. 解析时记录每个段落在原始 docx 中的段落索引 + xpath
2. 导出时复制原始模板文件
3. 遍历每个段落,找到对应位置替换内容:
- 纯文本:替换 `<w:t>` 节点文本
- 表格:删除原有表格占位,插入新表格的 XML 节点
4. 样式完全不修改(字体、字号、颜色、行距、段间距、页边距、页眉页脚、页码全部保留)
## 四、AI 并行生成设计
```
用户点击"开始生成"
解析模板段落依赖关系(当前无依赖,全部并行)
创建 asyncio.Task 池,Semaphore 控制并发数(默认 5
├── 段落1 → AI 请求 → SSE 推送完成
├── 段落2 → AI 请求 → SSE 推送完成
├── 段落3 → AI 请求 → SSE 推送完成
├── 段落4 → AI 请求 → SSE 推送完成
└── 段落5 → AI 请求 → SSE 推送完成
所有 Task 完成后,统一更新文档状态为 completed
SSE 推送 "全部完成",前端跳转到预览编辑
```
## 五、数据结构
### 5.1 模板 (template)
```
id: int (PK)
name: str
description: str
file_path: str # 原始模板文件路径
paragraph_count: int
status: str # draft / ready
created_at: datetime
updated_at: datetime
```
### 5.2 段落 (paragraph)
```
id: int (PK)
template_id: int (FK)
sort_index: int
title: str # 段落标题
content: str # 正文内容(供 AI 参考)
style_json: json # 完整样式定义
is_table: bool
table_json: json # 表格结构
edit_mode: str # manual / ai
model_id: int (FK, nullable)
need_prompt: bool
prompt_text: str
need_file: bool
file_note: str
output_format: str # text / table / mixed / chart
created_at: datetime
updated_at: datetime
```
### 5.3 AI 模型 (ai_model)
```
id: int (PK)
name: str
provider: str
api_format: str # anthropic / openai
api_endpoint: str
api_key_encrypted: str
status: str # enabled / disabled
created_at: datetime
updated_at: datetime
```
### 5.4 生成文档 (document)
```
id: int (PK)
template_id: int (FK)
name: str
para_count_done: int
para_count_total: int
status: str # pending / generating / completed / failed / cancelled
file_path: str # 生成的文档路径
error: str
created_at: datetime
updated_at: datetime
```
@@ -0,0 +1,133 @@
# 模板格式规范
## 一、对用户(模板提供方)的要求
### 必须遵守
| 要求 | 说明 |
|------|------|
| 标题样式 | 需要 AI 生成的段落,其标题必须应用 Word 内置标题样式(Heading 1~3 |
| 段落独立性 | 每个标题段落的内容应当主题独立,方便 AI 分别生成 |
### 强烈建议
| 建议 | 说明 |
|------|------|
| 正文提供参考内容 | 标题下的现有文本可作为 AI 生成的上下文,建议保留 |
| 表格上方有说明文字 | 表格前最好有一段文字说明,方便定位表格归属 |
| 文件名中文 | 模板文件名建议用中文,方便识别 |
### 不约束
| 项 | 说明 |
|----|------|
| 字体/配色/布局 | 不限,导出时完全保留 |
| 页眉页脚 | 不限,导出时完全保留 |
| 图片 | 不限,解析时保留占位,导出时保持不动 |
| 封面/附录 | 不限,不作为段落处理,导出时保留 |
## 二、解析规则(面向开发)
### 2.1 段落检测算法
```
for each paragraph in document.paragraphs:
style = paragraph.style.name
if style starts with "Heading":
→ 新段落开始
→ style 等级 = heading level (1~6)
→ 该段落为「标题段落」
elif style is "Normal" or None:
→ 属于上一个标题段落的「正文内容」
→ 追加到 paragraph.content
elif paragraph is inside a table cell:
→ 属于表格内容,跳过段落检测
```
### 2.2 表格归属
```
当前检测到的表格 → 归属于最近的标题段落
if 无标题段落:
→ 独立成段,段名 = "表格_{序号}"
```
### 2.3 样式捕获字段
对每个标题段落,捕获以下样式信息:
```json
{
"font": {
"name": "等线",
"eastAsia": "等线",
"size": 16,
"bold": true,
"italic": false,
"color": "000000"
},
"paragraph": {
"alignment": "CENTER",
"spaceBefore": 12,
"spaceAfter": 6,
"lineSpacing": 1.5,
"firstLineIndent": 0
},
"headingLevel": 1
}
```
### 2.4 表格样式捕获
```json
{
"rows": 5,
"cols": 6,
"gridSpan": {},
"cellStyles": [
{
"font": {"name": "宋体", "size": 10.5, "bold": false},
"shading": "D9E2F3",
"alignment": "CENTER",
"borders": {"top": "single", "bottom": "single", "left": "single", "right": "single"}
}
],
"tableWidth": 5000
}
```
## 三、AI 输出解析规则
### 3.1 强制输出 JSON
```
在 prompt 末尾附加:
请以 JSON 格式返回,不要包含任何其他说明文字。
{
"content": [
{"type": "text|table", ...}
]
}
```
### 3.2 JSON 解析
```
收到 AI 响应后:
1. 尝试解析为 JSON
2. 若解析失败,尝试从响应的 ```json ``` 代码块中提取
3. 若仍然失败,将整个响应作为纯文本处理(type=text)
```
### 3.3 表格插入
```
当 type=table:
1. 在原始 docx 中找到该段落后面的第一个表格占位
2. 删除占位表格的 XML 节点
3. 创建新表格(python-docx add_table
4. 逐格填充数据
5. 应用模板中该位置的表格样式(边框、底纹、对齐)
```
## 四、段落编辑方式
### 人工编辑
- 段落内容完全由用户手动输入
- 不参与 AI 生成流程
- 导出时保留用户输入的内容
### AI 生成
- 参与 AI 生成流程
- 可配置:生成模型、提示词、参考文件、输出格式
- 生成后可在预览编辑页手动修改
@@ -0,0 +1,112 @@
# AI 文档模板生成系统 · 任务拆解清单
总工期估算:5-6 周(两人并行:前端 + 后端)
## 第一阶段:架构与规范(第 1 周)
- [ ] 搭建前端脚手架(Vue 3 + Vite + TS + Ant Design Vue + Pinia + Router
- [ ] 搭建后端脚手架(FastAPI + SQLAlchemy async + SQLite
- [ ] 数据库表设计与建表
- [ ] 模板格式规范定稿(段落边界规则、标题样式要求、表格归属)
- [ ] AI 输出格式规范定稿(JSON 结构、表格标记、错误兜底)
- [ ] 导出策略定稿(基于原模板替换内容)
- [ ] 前后端 API 接口约定
## 第二阶段:后端核心开发(第 2-3 周)
### Word 解析器(5-7 天)
- [ ] python-docx 打开模板,逐段落遍历
- [ ] 标题样式识别(Heading 1~6)→ 段落边界
- [ ] 正文内容捕获 → 合并到上一标题
- [ ] 表格结构提取(行列数、合并单元格、边框、底纹)
- [ ] 样式捕获(字体名、字号、加粗、颜色、对齐、缩进、间距、行距)
- [ ] 段落索引记录(在文档中的位置,用于导出定位)
- [ ] 输出结构化 JSON
### AI 服务层(5-7 天)
- [ ] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
- [ ] Anthropic 格式适配(Claude 3.5 Sonnet
- [ ] 统一接口:call_ai(paragraph, files, callback) → content
- [ ] 提示词拼接:系统提示词 + 段落预设提示词 + 文件摘要
- [ ] 超时/重试/错误处理
- [ ] 并发控制(asyncio.Semaphore
- [ ] 文件摘要生成(Excel 解析 + 数据统计)
### 文档生成器(3 天)
- [ ] 单段落生成流程
- [ ] 多段落并行生成编排(asyncio.gather
- [ ] SSE 进度推送
- [ ] 取消生成支持
- [ ] 生成结果入库
### Word 导出引擎(5-7 天)
- [ ] 复制原模板文件作为骨架
- [ ] 段落定位(按索引/xpath/标题文本三级匹配)
- [ ] 纯文本替换(保留原样式)
- [ ] 表格填充(删除占位表格 → 创建新表格 → 填充数据 → 应用样式)
- [ ] 混合内容处理(文字 + 表格交替)
- [ ] PDF 导出(调用 LibreOffice 命令)
### 路由与 API3 天)
- [ ] 模板 CRUD 路由
- [ ] 模型 CRUD 路由
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
- [ ] 导出路由(Word/PDF
- [ ] 文件上传/管理
## 第三阶段:前端核心开发(第 2-4 周)
### 通用组件(3 天)
- [ ] FileUploader 组件(拖拽 + 点击上传)
- [ ] ModelModal 组件(添加/编辑模型弹窗)
- [ ] TestModal 组件(段落测试三步弹窗)
- [ ] 状态标签、加载状态、空状态组件
### 模板管理页(5-7 天)
- [ ] 模板列表:卡片布局 + 搜索 + 分页
- [ ] 三栏编辑器布局
- [ ] 左栏:段落列表(点击高亮 + 滚动联动)
- [ ] 中栏:文档预览(A4 纸样式,段落可点击选择)
- [ ] 右栏:段落配置面板(编辑方式/模型/提示词/文件/格式)
- [ ] 保存模板
### 模型管理页(2 天)
- [ ] 模型卡片列表 + 启用/禁用
- [ ] 添加/编辑弹窗表单
### 执行生成页(3 天)
- [ ] 双栏布局:左模板选择 + 右段落列表
- [ ] 文件上传区(按段落分列)
- [ ] 生成按钮 + 进度展示
- [ ] 完成跳转
### 生成记录页(2 天)
- [ ] 统计卡片
- [ ] 卡片式历史列表 + 筛选
- [ ] 预览/下载按钮
### 预览编辑页(3-4 天)
- [ ] 富文本编辑器(contenteditable + 自定义工具栏)
- [ ] AI 内容紫色高亮标注
- [ ] 重新生成单段落
- [ ] 保存/导出按钮
## 第四阶段:联调与修边(第 5-6 周)
- [ ] 文件上传 → 解析 → 保存 → 生成 → 导出全流程联调
- [ ] 模板不同格式兼容性测试(不同字体、不同布局、含表格/图片/页眉页脚)
- [ ] AI 不同模型的输出格式兼容性
- [ ] 错误处理完善(网络中断、模型不可用、文件格式错误)
- [ ] 边界情况处理(空模板、超大文件、特殊字符)
- [ ] 响应式适配
- [ ] 加载状态/骨架屏
- [ ] 操作提示/Toast
## 阶段交付物
| 阶段 | 交付物 |
|------|--------|
| 第 1 周 | 项目脚手架、数据库表、API 接口文档 |
| 第 2-3 周 | 后端全部功能可用(可通过 API 测试) |
| 第 4-5 周 | 前端全部页面可用,可联调 |
| 第 6 周 | 全流程跑通,交付验收 |
+76
View File
@@ -0,0 +1,76 @@
CREATE DATABASE IF NOT EXISTS doc_forge CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE doc_forge;
CREATE TABLE IF NOT EXISTS templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL COMMENT '模板名称',
description TEXT DEFAULT '' COMMENT '描述',
file_path VARCHAR(500) NOT NULL COMMENT 'MinIO 对象路径',
paragraph_count INT DEFAULT 0 COMMENT '段落数',
status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/ready',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS ai_models (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL COMMENT '模型名称',
provider VARCHAR(100) DEFAULT '' COMMENT '供应厂商',
api_format VARCHAR(20) DEFAULT 'openai' COMMENT 'anthropic/openai',
api_endpoint VARCHAR(500) DEFAULT '' COMMENT 'API 地址',
api_key_encrypted TEXT DEFAULT '' COMMENT '加密后的 API Key',
status VARCHAR(20) DEFAULT 'enabled' COMMENT 'enabled/disabled',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS paragraphs (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
sort_index INT DEFAULT 0 COMMENT '排序',
title VARCHAR(500) DEFAULT '' COMMENT '段落标题',
content TEXT DEFAULT '' COMMENT '正文内容',
style_json TEXT DEFAULT '{}' COMMENT '样式 JSON',
is_table TINYINT(1) DEFAULT 0 COMMENT '是否为表格',
table_json TEXT DEFAULT '{}' COMMENT '表格结构 JSON',
edit_mode VARCHAR(20) DEFAULT 'ai' COMMENT 'manual/ai',
model_id INT DEFAULT NULL COMMENT '指定模型',
need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词',
prompt_text TEXT DEFAULT '' COMMENT '预设提示词',
need_file TINYINT(1) DEFAULT 0 COMMENT '是否需要参考文件',
file_note TEXT DEFAULT '' COMMENT '备注说明',
output_format VARCHAR(20) DEFAULT 'text' COMMENT 'text/table/mixed/chart',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS documents (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
name VARCHAR(255) DEFAULT '' COMMENT '文档名称',
para_count_done INT DEFAULT 0 COMMENT '已完成段落数',
para_count_total INT DEFAULT 0 COMMENT '总段落数',
status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/generating/completed/failed/cancelled',
file_path VARCHAR(500) DEFAULT '' COMMENT 'MinIO 对象路径',
error TEXT DEFAULT '' COMMENT '错误信息',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS generation_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
document_id INT NOT NULL,
paragraph_id INT NOT NULL,
model_id INT DEFAULT NULL,
status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/generating/success/failed',
content TEXT DEFAULT '' COMMENT '生成的内容',
duration FLOAT DEFAULT 0 COMMENT '耗时秒数',
error_msg TEXT DEFAULT '' COMMENT '错误信息',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY (paragraph_id) REFERENCES paragraphs(id) ON DELETE CASCADE,
FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+4
View File
@@ -0,0 +1,4 @@
<!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>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ai-doc-template-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"ant-design-vue": "^4.1.0",
"@ant-design/icons-vue": "^7.0.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.1.0",
"vue-tsc": "^2.0.0"
}
}
+1384
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
<template>
<a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }">
<a-layout style="min-height:100vh">
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible theme="light" :width="220">
<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="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header style="background:#fff;padding:0 24px;display:flex;align-items:center;border-bottom:1px solid #f0f0f0">
<menu-unfold-outlined v-if="collapsed" @click="collapsed=false" style="font-size:18px;cursor:pointer" />
<menu-fold-outlined v-else @click="collapsed=true" style="font-size:18px;cursor:pointer" />
<div style="flex:1" />
<a-badge :count="0" :overflow-count="99"><bell-outlined style="font-size:18px;color:#999" /></a-badge>
<span style="margin-left:16px;font-size:13px;color:#666">周文涛</span>
</a-layout-header>
<a-layout-content style="margin:0;background:#f5f6f8;overflow:auto">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</a-config-provider>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
FolderOutlined, CpuOutlined, ThunderboltOutlined, ClockCircleOutlined,
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const selectedKeys = ref([route.path])
watch(() => route.path, p => { selectedKeys.value = [p] })
function onMenuClick(info: any) {
router.push(info.key)
}
</script>
<style scoped>
.logo { height: 52px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: #5b5bd6; border-bottom: 1px solid #f0f0f0; }
</style>
+13
View File
@@ -0,0 +1,13 @@
import http from './index'
export const generateApi = {
test: (data: any) => http.post('/generate/test', data),
full: (data: any) => http.post('/generate/full', data),
progress: (id: number) => `/api/v1/generate/progress/${id}`,
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
documents: (params?: any) => http.get('/generate/documents', { params }),
getDocument: (id: number) => http.get(`/generate/documents/${id}`),
deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`),
exportDocx: (id: number) => `/api/v1/export/${id}/docx`,
exportPdf: (id: number) => `/api/v1/export/${id}/pdf`,
}
+14
View File
@@ -0,0 +1,14 @@
import axios from 'axios'
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })
http.interceptors.response.use(
res => res.data,
err => {
const msg = err.response?.data?.message || err.message || '网络错误'
console.error('[API Error]', msg)
return Promise.reject({ code: -1, message: msg })
}
)
export default http
+9
View File
@@ -0,0 +1,9 @@
import http from './index'
export const modelApi = {
list: () => http.get('/models'),
create: (data: any) => http.post('/models', data),
update: (id: number, data: any) => http.put(`/models/${id}`, data),
delete: (id: number) => http.delete(`/models/${id}`),
test: (id: number) => http.post(`/models/${id}/test`),
}
+9
View File
@@ -0,0 +1,9 @@
import http from './index'
export const templateApi = {
list: (params?: any) => http.get('/templates', { params }),
get: (id: number) => http.get(`/templates/${id}`),
upload: (formData: FormData) => http.post('/templates/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
saveParagraphs: (id: number, data: any) => http.put(`/templates/${id}/paragraphs`, data),
delete: (id: number) => http.delete(`/templates/${id}`),
}
+6
View File
@@ -0,0 +1,6 @@
<template><div ref="containerRef" style="overflow-y:auto;padding:24px;display:flex;justify-content:center;background:#f0f1f3;flex:1"><div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08);font-size:14px;line-height:1.8"><slot /></div></div></template>
<script setup lang="ts">
import { ref } from "vue";
const containerRef = ref<HTMLElement|null>(null);
defineExpose({containerRef})
</script>
+6
View File
@@ -0,0 +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>
<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>
+10
View File
@@ -0,0 +1,10 @@
<template><a-modal v-model:open="visible" :title="isEdit?'编辑模型':'添加模型'" @ok="$emit('save',form)"><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" /></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></template>
<script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{open:boolean;isEdit:boolean;initialData?:any}>()
const emit = defineEmits<{save:[data:any];close:[]}>()
const visible = ref(false)
const form = ref({name:'',provider:'',api_format:'openai',api_endpoint:'',api_key:''})
watch(()=>props.open,v=>{visible.value=v;if(v&&props.isEdit&&props.initialData)form.value={...form.value,...props.initialData}})
watch(visible,v=>{if(!v)emit('close')})
</script>
+5
View File
@@ -0,0 +1,5 @@
<template><div><a-form layout="vertical"><a-form-item label="编辑方式"><a-select v-model:value="data.edit_mode"><a-select-option value="ai">AI 生成</a-select-option><a-select-option value="manual">人工编辑</a-select-option></a-select></a-form-item><a-form-item v-if="data.edit_mode==='ai'" label="生成模型"><a-select v-model:value="data.model_id" allowClear placeholder="使用默认模型"><a-select-option v-for="m in models" :key="m.id" :value="m.id">{{m.name}}</a-select-option></a-select></a-form-item><a-form-item label="输出格式"><a-select v-model:value="data.output_format"><a-select-option value="text">正式报告段落</a-select-option><a-select-option value="table">表格形式</a-select-option><a-select-option value="mixed">混合内容</a-select-option></a-select></a-form-item><a-form-item label="需要提示词"><a-switch v-model:checked="data.need_prompt" /></a-form-item><a-form-item v-if="data.need_prompt" label="预设提示词"><a-textarea v-model:value="data.prompt_text" :rows="4" /></a-form-item><a-form-item label="需要参考文件"><a-switch v-model:checked="data.need_file" /></a-form-item><a-form-item v-if="data.need_file" label="备注"><a-input v-model:value="data.file_note" placeholder="提示上传什么文件" /></a-form-item></a-form><a-button v-if="data.edit_mode==='ai'" type="primary" block @click="$emit('test')">立即测试</a-button></div></template>
<script setup lang="ts">
defineProps<{data:any;models:any[]}>()
defineEmits<{test:[]}>()
</script>
+6
View File
@@ -0,0 +1,6 @@
<template><div class="para-list"><div v-for="(p,i) in paragraphs" :key="p.id" :class="['para-item',{active:activeId===p.id}]" @click="$emit('select',p.id)"><span class="idx">{{i+1}}</span><span class="title">{{p.title||"段落"+p.id}}</span><a-tag :color="p.edit_mode==='ai'?'blue':'orange'" size="small">{{p.edit_mode==='ai'?'AI':'手动'}}</a-tag></div></div></template>
<script setup lang="ts">
defineProps<{paragraphs:any[];activeId:number}>()
defineEmits<{select:[id:number]}>()
</script>
<style scoped>.para-list{padding:8px}.para-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;margin-bottom:2px;font-size:13px}.para-item:hover{background:#f5f5f5}.para-item.active{background:#f0f0ff;color:#5b5bd6;font-weight:500}.para-item .idx{width:20px;height:20px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.para-item.active .idx{background:#5b5bd6;color:#fff}.para-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}</style>
+13
View File
@@ -0,0 +1,13 @@
<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>
<script setup lang="ts">
import { ref, watch } from "vue";
import { FileAddOutlined } from "@ant-design/icons-vue";
const props = defineProps<{open:boolean}>()
const emit = defineEmits<{close:[]}>()
const visible = ref(false)
const step = ref(0)
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>
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
declare module "*.vue" { import type { DefineComponent } from "vue"; const comp: DefineComponent<{}, {}, any>; export default comp; }
+12
View File
@@ -0,0 +1,12 @@
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'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(Antd)
app.mount('#app')
+14
View File
@@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', redirect: '/templates' },
{ path: '/templates', name: 'TemplateList', component: () => import('@/views/TemplateList.vue') },
{ path: '/templates/:id/edit', name: 'TemplateEditor', component: () => import('@/views/TemplateEditor.vue') },
{ path: '/models', name: 'ModelManage', component: () => import('@/views/ModelManage.vue') },
{ path: '/generate', name: 'GeneratePage', component: () => import('@/views/GeneratePage.vue') },
{ path: '/history', name: 'HistoryPage', component: () => import('@/views/HistoryPage.vue') },
{ path: '/preview/:id', name: 'PreviewEdit', component: () => import('@/views/PreviewEdit.vue') },
]
const router = createRouter({ history: createWebHistory(), routes })
export default router
+17
View File
@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { generateApi } from '@/api/generate'
import type { Document } from '@/types'
export const useDocumentStore = defineStore('document', () => {
const documents = ref<Document[]>([])
const currentDoc = ref<Document | null>(null)
const loading = ref(false)
async function fetchList(params?: any) { loading.value = true; try { const r: any = await generateApi.documents(params); documents.value = r.data?.items || r.data || [] } finally { loading.value = false } }
async function generateFull(data: any) { const r: any = await generateApi.full(data); return r.data }
async function cancel(id: number) { await generateApi.cancel(id) }
async function removeDoc(id: number) { await generateApi.deleteDocument(id); await fetchList() }
return { documents, currentDoc, loading, fetchList, generateFull, cancel, removeDoc }
})
+17
View File
@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { modelApi } from '@/api/model'
import type { AiModel } from '@/types'
export const useModelStore = defineStore('model', () => {
const models = ref<AiModel[]>([])
const loading = ref(false)
async function fetchList() { loading.value = true; try { const r: any = await modelApi.list(); models.value = r.data || [] } finally { loading.value = false } }
async function create(data: any) { await modelApi.create(data); await fetchList() }
async function update(id: number, data: any) { await modelApi.update(id, data); await fetchList() }
async function remove(id: number) { await modelApi.delete(id); await fetchList() }
async function test(id: number) { return await modelApi.test(id) }
return { models, loading, fetchList, create, update, remove, test }
})
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { templateApi } from '@/api/template'
import type { Template, Paragraph } from '@/types'
export const useTemplateStore = defineStore('template', () => {
const templates = ref<Template[]>([])
const currentTemplate = ref<Template | null>(null)
const paragraphs = ref<Paragraph[]>([])
const loading = ref(false)
async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } }
async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
async function save(id: number) { const data = paragraphs.value.map(p => ({ id: p.id, sort_index: p.sort_index, title: p.title, edit_mode: p.edit_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) }
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
})
+27
View File
@@ -0,0 +1,27 @@
export interface Template {
id: number; name: string; description: string; file_path: string
paragraph_count: number; status: string; created_at: string; updated_at: string
}
export interface Paragraph {
id: number; template_id: number; sort_index: number; title: string; content: string
style_json: string; is_table: boolean; table_json: string
edit_mode: 'manual' | 'ai'; model_id: number | null
need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string
output_format: 'text' | 'table' | 'mixed' | 'chart'
}
export interface AiModel {
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
api_endpoint: string; api_key_preview: string; status: 'enabled' | 'disabled'
}
export interface Document {
id: number; template_id: number; name: string
para_count_done: number; para_count_total: number
status: 'pending' | 'generating' | 'completed' | 'failed' | 'cancelled'
file_path: string; error: string
}
export interface ApiResponse<T = any> { code: number; data: T; message: string }
export interface PageData<T = any> { items: T[]; total: number; page: number; page_size: number }
+26
View File
@@ -0,0 +1,26 @@
<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>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { useDocumentStore } from "@/stores/document";
import { message } from "ant-design-vue";
const router = useRouter();
const tplStore = useTemplateStore();
const docStore = useDocumentStore();
const templates = ref<any[]>([]);
const paragraphs = ref<any[]>([]);
const selectedTplId = ref(undefined);
const uploadedFiles = ref<Record<number,string>>({});
const generating = ref(false);
const progress = ref(0);
const progressText = ref("");
const tplInfo = ref<any>({});
const needFileCount = computed(()=>paragraphs.value.filter(p=>p.need_file).length);
const fileCount = computed(()=>Object.keys(uploadedFiles.value).length);
onMounted(async()=>{await tplStore.fetchList();templates.value=tplStore.templates as any});
async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);paragraphs.value=tplStore.paragraphs as any;tplInfo.value={paragraph_count:tpl.paragraph_count,fileCount:paragraphs.value.filter((p:any)=>p.need_file).length}}
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>
+14
View File
@@ -0,0 +1,14 @@
<template><div style="padding:24px"><a-row :gutter="16" style="margin-bottom:24px"><a-col :span="6"><a-card><a-statistic title="总生成" :value="12" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="成功" :value="10" value-style="color:#52c41a" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="中断" :value="1" value-style="color:#faad14" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="失败" :value="1" value-style="color:#ff4d4f" /></a-card></a-col></a-row><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h3>生成记录</h3><div><a-select style="width:140px;margin-right:8px" placeholder="全部模板" /><a-select style="width:120px" placeholder="全部状态" /></div></div><a-list :dataSource="documents" :grid="{gutter:16,xs:1,sm:1,md:2,lg:2,xl:3,xxl:3}"><template #renderItem="{item}"><a-list-item><a-card hoverable><a-card-meta :title="item.name"><template #description><p>模板{{item.template_id}}</p><p>段落{{item.para_count_done}}/{{item.para_count_total}}</p><p>状态<a-badge :status="item.status==='completed'?'success':item.status==='failed'?'error':'processing'" :text="statusText(item.status)" /></p></template></a-card-meta><template #actions><a-button type="link" @click="preview(item.id)">预览</a-button><a-button type="link" @click="download(item.id)">下载</a-button></template></a-card></a-list-item></template></a-list></div></template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useDocumentStore } from "@/stores/document";
import { generateApi } from "@/api/generate";
const router = useRouter();
const store = useDocumentStore();
const documents = ref<any[]>([]);
function statusText(s:string){const m:Record<string,string>={'completed':'已生成','failed':'失败','cancelled':'中断','generating':'生成中','pending':'等待中'};return m[s]||s}
onMounted(async()=>{await store.fetchList();documents.value=store.documents as any});
function preview(id:number){router.push(`/preview/${id}`)}
function download(id:number){window.open(generateApi.exportDocx(id))}
</script>
+17
View File
@@ -0,0 +1,17 @@
<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>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useModelStore } from "@/stores/model";
import { message } from "ant-design-vue";
const store = useModelStore();
const models = ref<any[]>([]);
const modalOpen = ref(false);
const isEdit = ref(false);
const editId = ref(0);
const form = ref({name:"",provider:"",api_format:"openai",api_endpoint:"",api_key:""});
onMounted(async()=>{await store.fetchList();models.value=store.models as any});
function openAdd(){isEdit.value=false;form.value={name:"",provider:"",api_format:"openai",api_endpoint:"",api_key:""};modalOpen.value=true}
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>
+19
View File
@@ -0,0 +1,19 @@
<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>
<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";
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>`);
const structure = ref([{id:'s1',title:'经营指标',active:true},{id:'s2',title:'指标表',active:false},{id:'s3',title:'成本费用',active:false}]);
function scrollTo(id:string){}
function onEdit(){}
function undo(){document.execCommand('undo')}
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>
+21
View File
@@ -0,0 +1,21 @@
<template><div style="display:flex;height:calc(100vh - 112px)"><div style="width:220px;border-right:1px solid #f0f0f0;overflow-y:auto;padding:12px"><h4 style="margin-bottom:12px">段落列表</h4><div v-for="(p,i) in paragraphs" :key="p.id" :class="['para-item',{active:selectedId===p.id}]" @click="selectPara(p.id)"><span class="idx">{{i+1}}</span><span class="title">{{p.title||'未命名'}}</span><a-tag :color="p.edit_mode==='ai'?'blue':'orange'" style="margin-left:auto">{{p.edit_mode==='ai'?'AI':'手动'}}</a-tag></div></div><div style="flex:1;overflow-y:auto;padding:24px;background:#f0f1f3;display:flex;justify-content:center;align-items:flex-start"><div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08)" v-html="previewHtml"></div></div><div style="width:380px;border-left:1px solid #f0f0f0;overflow-y:auto;padding:16px"><h4>段落配置</h4><div v-if="selectedPara"><a-form layout="vertical"><a-form-item label="编辑方式"><a-select v-model:value="selectedPara.edit_mode"><a-select-option value="ai">AI 生成</a-select-option><a-select-option value="manual">人工编辑</a-select-option></a-select></a-form-item><a-form-item v-if="selectedPara.edit_mode==='ai'" label="生成模型"><a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型"><a-select-option v-for="m in models" :key="m.id" :value="m.id">{{m.name}}</a-select-option></a-select></a-form-item><a-form-item label="输出格式"><a-select v-model:value="selectedPara.output_format"><a-select-option value="text">正式报告段落</a-select-option><a-select-option value="table">表格形式</a-select-option><a-select-option value="mixed">混合内容</a-select-option><a-select-option value="chart">图表形式</a-select-option></a-select></a-form-item><a-form-item label="需要提示词"><a-switch v-model:checked="selectedPara.need_prompt" /></a-form-item><a-form-item v-if="selectedPara.need_prompt" label="预设提示词"><a-textarea v-model:value="selectedPara.prompt_text" :rows="4" placeholder="在此输入提示词..." /></a-form-item><a-form-item label="需要参考文件"><a-switch v-model:checked="selectedPara.need_file" /></a-form-item><a-form-item v-if="selectedPara.need_file" label="备注说明"><a-textarea v-model:value="selectedPara.file_note" :rows="2" placeholder="提示用户上传什么文件" /></a-form-item></a-form><a-button v-if="selectedPara.edit_mode==='ai'" type="primary" block @click="testPara">立即测试</a-button></div></div></div></template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { useModelStore } from "@/stores/model";
import { message } from "ant-design-vue";
const route = useRoute();
const router = useRouter();
const store = useTemplateStore();
const modelStore = useModelStore();
const paragraphs = ref<any[]>([]);
const models = ref<any[]>([]);
const selectedId = ref(0);
const selectedPara = computed(()=>paragraphs.value.find(p=>p.id===selectedId.value));
const previewHtml = computed(()=>{return paragraphs.value.map(p=>'<div style="margin:8px 0;padding:8px;border:2px solid transparent;border-radius:4px'+(selectedId.value===p.id?';border-color:#5b5bd6;background:#f0f0ff':'')+'"><h3>'+(p.title||"")+'</h3><p>'+(p.content||"点击左侧配置此段落")+'</p></div>').join("")});
onMounted(async()=>{const id = Number(route.params.id);await store.fetchOne(id);paragraphs.value=store.paragraphs as any;await modelStore.fetchList();models.value=modelStore.models as any;if(paragraphs.value.length)selectedId.value=paragraphs.value[0].id});
function selectPara(id:number){selectedId.value=id;const el=document.getElementById('para-'+id);if(el)el.scrollIntoView({behavior:'smooth',block:'center'})}
async function testPara(){message.info('段落测试功能待实现')}
</script>
<style scoped>.para-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;margin-bottom:2px;font-size:13px}.para-item:hover{background:#f5f5f5}.para-item.active{background:#f0f0ff;color:#5b5bd6;font-weight:500}.para-item .idx{width:20px;height:20px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}.para-item.active .idx{background:#5b5bd6;color:#fff}.para-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}</style>
+17
View File
@@ -0,0 +1,17 @@
<template><div style="padding:24px"><h2>模板管理</h2><a-button type="primary" @click="handleUpload">新建模板</a-button><a-table :dataSource="templates" :columns="columns" rowKey="id" style="margin-top:16px" /><a-modal v-model:open="uploadOpen" title="上传模板" @ok="doUpload"><a-upload-dragger :beforeUpload="beforeUpload"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击或拖拽 Word 模板文件到此区域</p></a-upload-dragger></a-modal></div></template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { FileAddOutlined } from "@ant-design/icons-vue";
const router = useRouter();
const store = useTemplateStore();
const templates = ref([]);
const uploadOpen = ref(false);
const uploadFile = ref<File | null>(null);
const columns = [{title:"名称",dataIndex:"name"},{title:"段落",dataIndex:"paragraph_count"},{title:"状态",dataIndex:"status"},{title:"操作",key:"action"}];
onMounted(async()=>{await store.fetchList();templates.value=store.templates as any});
function handleUpload(){uploadOpen.value=true}
function beforeUpload(file:File){uploadFile.value=file;return false}
async function doUpload(){if(!uploadFile.value)return;await store.upload(uploadFile.value);uploadOpen.value=false;router.push(`/templates/${store.currentTemplate?.id}/edit`)}
</script>
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2020", "module": "ESNext", "moduleResolution": "bundler",
"strict": true, "jsx": "preserve", "resolveJsonModule": true,
"isolatedModules": true, "esModuleInterop": true, "lib": ["ES2020", "DOM"],
"skipLibCheck": true, "noEmit": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "composite": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true },
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/uploads': { target: 'http://localhost:8000', changeOrigin: true },
'/outputs': { target: 'http://localhost:8000', changeOrigin: true }
}
}
})