Compare commits

..

15 Commits

Author SHA1 Message Date
zwt13703 17e02fd799 docs: task log 2026-07-05 23:36:23 +08:00
zwt13703 c84ec6aa71 模板在线编辑与导出链路重构 2026-07-05 23:35:00 +08:00
zwt13703 1369d87afb 支持段落删除与移动排序,修复导出残留与外键约束
- 前端:段落列表/配置区/手动编辑区新增上移、下移、删除按钮,hover 显示
- 前端:移动和删除操作后自动保存到后端,修正 canDeleteBlock 判定逻辑
- 后端:保存段落时级联删除关联的 generation_logs 再删段落
- 后端:导出时清理未被引用的标题段落及其内容,避免已删段落残留在 Word 中
- 后端:删除模板时级联清理 generation_logs/documents/paragraphs
2026-07-03 17:13:02 +08:00
zwt13703 a796301ae8 完善模板编辑与模型管理体验 2026-07-03 11:14:24 +08:00
zwt13703 52eb058070 调整任务详情页左右预览布局 2026-07-03 09:28:39 +08:00
zwt13703 ff6dacd136 修正导出文档内容回写与样式保留 2026-07-02 18:34:21 +08:00
zwt13703 401e8cf57b 完善附件管理与生成任务跟踪 2026-07-02 18:26:50 +08:00
zwt13703 d3530ab0b7 完善模型测试与参考文件历史能力 2026-07-02 17:37:35 +08:00
zwt13703 8743a02110 完善模板测试弹窗与多文件解析链路 2026-07-02 16:35:50 +08:00
zwt13703 a8716aa3c6 按原型图重构核心页面并完善生成链路 2026-07-02 16:09:34 +08:00
zwt13703 5a43cc70d5 接入真实模型调用与参考文件上传 2026-07-02 15:16:34 +08:00
zwt13703 b313083766 补充运行说明并实现基础导出能力 2026-07-02 15:13:57 +08:00
zwt13703 a2bad58591 补齐模型管理与基础生成预览链路 2026-07-02 15:00:11 +08:00
zwt13703 b15a3a1f18 实现模板解析与模板管理基础链路 2026-07-02 14:56:54 +08:00
zwt13703 e558733f05 init 2026-07-02 14:48:05 +08:00
81 changed files with 10857 additions and 2 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
__pycache__/
*.py[cod]
*$py.class
.idea
# C extensions
*.so
+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
- 样式完全保留(字体/颜色/行距/页边距/页眉页脚)
+190 -1
View File
@@ -1,3 +1,192 @@
# 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
## 当前可用能力
- 上传 `.docx` 模板并按 Heading 1~6 解析段落
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
- 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果
- 生成过程中支持 SSE 进度推送与取消生成
- 查看生成记录与预览页真实结果
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
## 运行方式
推荐开发方式:`Docker 启动依赖 + 本地启动前后端`
### 1. 启动 MySQL 和 MinIO
在项目根目录执行:
```bash
docker compose up -d mysql minio
```
启动后可访问:
- MySQL: `localhost:3306`
- MinIO API: `http://localhost:9000`
- MinIO Console: `http://localhost:9001`
默认账号:
- MinIO 用户名: `docforge`
- MinIO 密码: `docforge123`
### 2. 启动后端
```bash
cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
后端地址:
- API 根地址: `http://localhost:8000`
- 健康检查: `http://localhost:8000/health`
后端默认读取 [backend/.env](/Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend/.env)
```env
DEBUG=True
DB_HOST=localhost
DB_PORT=3306
DB_USER=docforge
DB_PASSWORD=docforge123
DB_NAME=doc_forge
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=docforge
MINIO_SECRET_KEY=docforge123
MINIO_USE_SSL=False
ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!!
```
### 3. 启动前端
```bash
cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/web
pnpm install
pnpm dev
```
前端地址:
- `http://localhost:5173`
### 4. 初步使用流程
1. 打开“模板管理”,上传一个 `.docx` 模板。
2. 进入模板编辑页,为段落配置 AI / 手动、提示词、模型等。
3. 打开“模型管理”,添加至少一个模型配置。
4. 打开“执行生成”,选择模板并发起生成。
5. 到“生成记录”查看历史,点击“预览”查看实际生成内容。
6. 在预览页点击“导出 Word”下载导出文件。
## Docker 全套启动
如果想直接用 Docker 跑全套,可以在项目根目录准备 `.env`
```env
ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!!
```
然后执行:
```bash
docker compose up -d
```
暴露端口:
- 前端: `5173`
- 后端: `8000`
- MinIO: `9000`
- MinIO Console: `9001`
- MySQL: `3306`
## 常见问题
### `.idea` 不小心提交了怎么办?
不影响项目运行,但建议尽快移除并加入忽略:
```bash
echo ".idea/" >> .gitignore
git rm -r --cached .idea
git add .gitignore
git commit -m "移除 IDE 配置文件"
```
### 为什么我本地 `python main.py` 报缺少模块?
说明当前 Python 环境还没安装依赖,先执行:
```bash
pip install -r requirements.txt
```
推荐用虚拟环境:
```bash
python3 -m venv .venv
source .venv/bin/activate
```
### 为什么导出 PDF 还不可用?
当前阶段已经支持基础 Word 导出,PDF 导出还未接入 LibreOffice 转换流程。
## 目录结构
```text
doc-forge/
├── web/ Vue 3 前端
│ └── src/
│ ├── views/ 页面
│ ├── components/ 通用组件
│ ├── api/ Axios 请求层
│ ├── stores/ Pinia 状态管理
│ └── router/ 路由配置
├── backend/ Python FastAPI 后端
│ ├── models/ ORM 数据模型
│ ├── routers/ API 路由
│ ├── schemas/ Pydantic 校验
│ ├── services/ 业务逻辑层
│ ├── config.py 配置
│ └── database.py 异步数据库引擎
├── docker-compose.yml MySQL + MinIO + 后端 + 前端
├── init.sql 数据库初始化 SQL
└── docs/ 项目文档
```
## 核心流程
```text
上传模板 → 解析段落 → 标注配置 → 保存模板
→ 执行生成(上传文件 → AI 生成)
→ 预览编辑 → 导出 Word
```
## 环境要求
- Python 3.11+
- Node.js 18+
- pnpm 8+
- Docker + Docker Compose
- 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", ".doc", ".xlsx", ".xls", ".xlsm", ".csv", ".pdf", ".txt", ".md", ".json"]
# 加密(用于 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)
+59
View File
@@ -0,0 +1,59 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import inspect, text
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.template_block import TemplateBlock
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.reference_file import ReferenceFile
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
dialect_name = conn.dialect.name
columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")])
if "supports_streaming" not in columns:
if dialect_name == "sqlite":
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming BOOLEAN DEFAULT 0"))
else:
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming TINYINT(1) DEFAULT 0"))
if "enable_reasoning" not in columns:
if dialect_name == "sqlite":
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning BOOLEAN DEFAULT 0"))
else:
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning TINYINT(1) DEFAULT 0"))
document_columns = await conn.run_sync(
lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("documents")]
)
if "request_payload_json" not in document_columns:
await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT"))
paragraph_columns = await conn.run_sync(
lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("paragraphs")]
)
if "anchor_title" not in paragraph_columns:
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN anchor_title VARCHAR(500) DEFAULT ''"))
await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL"))
if "write_mode" not in paragraph_columns:
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'"))
block_tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
if "template_blocks" not in block_tables:
await conn.run_sync(lambda sync_conn: TemplateBlock.__table__.create(sync_conn))
+57
View File
@@ -0,0 +1,57 @@
import uvicorn
from contextlib import asynccontextmanager
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
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.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 {"code": 0, "data": {"name": settings.APP_NAME, "version": settings.APP_VERSION}, "message": "ok"}
@app.get("/health")
async def health():
return {"code": 0, "data": {"status": "ok"}, "message": "ok"}
if __name__ == "__main__":
uvicorn.run("main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG)
+6
View File
@@ -0,0 +1,6 @@
from models.template import Template
from models.paragraph import Paragraph
from models.template_block import TemplateBlock
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, 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")
supports_streaming = Column(Boolean, default=False, comment="是否支持流式传输")
enable_reasoning = Column(Boolean, default=False, comment="是否开启思考模式")
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())
+16
View File
@@ -0,0 +1,16 @@
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="错误信息")
request_payload_json = Column(Text, nullable=True, 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())
+24
View File
@@ -0,0 +1,24 @@
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="排序")
anchor_title = Column(String(500), default="", 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="manual", comment="manual/ai")
write_mode = Column(String(30), default="replace_section", comment="replace_section/append_after_heading/replace_heading_only")
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())
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class ReferenceFile(Base):
__tablename__ = "reference_files"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
file_name: Mapped[str] = mapped_column(String(255), default="", comment="原始文件名")
file_path: Mapped[str] = mapped_column(String(500), default="", comment="MinIO 对象路径")
file_size: Mapped[int] = mapped_column(Integer, default=0, comment="文件大小")
content_type: Mapped[str] = mapped_column(String(120), default="", comment="文件类型")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=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())
+30
View File
@@ -0,0 +1,30 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func
from database import Base
class TemplateBlock(Base):
__tablename__ = "template_blocks"
id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
source_paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True)
parent_block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True)
sort_index = Column(Integer, default=0, comment="排序")
block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable")
anchor_ref = Column(String(500), default="", comment="原始锚点引用")
title = Column(String(500), default="", comment="块标题")
content_json = Column(Text, default="{}", comment="块内容 JSON")
style_json = Column(Text, default="{}", comment="块样式 JSON")
edit_mode = Column(String(20), default="manual", comment="manual/ai")
placeholder_key = Column(String(120), default="", comment="AI 占位键")
variable_key = Column(String(120), default="", comment="变量键")
default_value = Column(Text, default="", comment="默认值")
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())
+19
View File
@@ -0,0 +1,19 @@
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
xlrd>=2.0.1
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
pypdf>=5.0.0
View File
+150
View File
@@ -0,0 +1,150 @@
import asyncio
import json
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import PlainTextResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from models.template_block import TemplateBlock
from services.document_export import export_document_bytes
from services.minio_client import (
download_object_bytes,
get_presigned_url,
split_bucket_path,
upload_bytes,
)
router = APIRouter()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
def _build_block_export_content(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
@router.get("/{document_id}/docx")
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
template = await db.get(Template, document.template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
block_result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template.id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = block_result.scalars().all()
log_result = await db.execute(
select(GenerationLog).where(GenerationLog.document_id == document_id)
)
generation_logs = log_result.scalars().all()
log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs}
logs = []
if blocks:
write_modes = _resolve_block_write_modes(blocks)
for block, write_mode in zip(blocks, write_modes):
generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block)
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": content,
}
)
else:
result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
for log, paragraph in result.all():
logs.append(
{
"anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"write_mode": paragraph.write_mode,
"content": json.loads(log.content) if log.content else {"content": []},
}
)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
await asyncio.to_thread(
upload_bytes,
settings.MINIO_BUCKET_OUTPUTS,
object_name,
exported_bytes,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}"
await db.commit()
return RedirectResponse(
url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name),
status_code=307,
)
@router.get("/{document_id}/pdf")
async def export_pdf(document_id: int):
return PlainTextResponse(
f"文档 {document_id} 的 PDF 导出功能正在开发中,当前版本请先使用预览页查看结果。",
media_type="text/plain; charset=utf-8",
)
+462
View File
@@ -0,0 +1,462 @@
import asyncio
import json
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse
from config import settings
from database import get_db
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.reference_file import ReferenceFile
from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response
from services.ai_service import call_ai, stream_ai_preview
from services.file_summary import summarize_minio_files
from services.generation_runtime import (
build_mock_content,
generation_progress,
request_cancel,
run_generation,
update_progress,
)
from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url
router = APIRouter()
def _serialize_document(document: Document) -> dict:
request_payload = {}
if document.request_payload_json:
try:
request_payload = json.loads(document.request_payload_json)
except Exception:
request_payload = {}
return {
"id": document.id,
"template_id": document.template_id,
"name": document.name,
"para_count_done": document.para_count_done,
"para_count_total": document.para_count_total,
"status": document.status,
"file_path": document.file_path,
"error": document.error,
"request_payload": request_payload,
"created_at": document.created_at,
"updated_at": document.updated_at,
}
def _serialize_reference_file(file: ReferenceFile) -> dict:
return {
"id": file.id,
"file_name": file.file_name,
"file_path": file.file_path,
"file_size": file.file_size,
"content_type": file.content_type,
"created_at": file.created_at.isoformat() if file.created_at else None,
}
async def _build_reference_name_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, str]:
if not file_paths:
return {}
result = await db.execute(
select(ReferenceFile.file_path, ReferenceFile.file_name).where(ReferenceFile.file_path.in_(file_paths))
)
return {file_path: file_name for file_path, file_name in result.all()}
async def _build_reference_records_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, ReferenceFile]:
if not file_paths:
return {}
result = await db.execute(select(ReferenceFile).where(ReferenceFile.file_path.in_(file_paths)))
return {item.file_path: item for item in result.scalars().all()}
@router.post("/test")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
if paragraph is None or paragraph.template_id != body.template_id:
raise HTTPException(status_code=404, detail="段落不存在")
if body.prompt_text:
paragraph.prompt_text = body.prompt_text
model = None
if body.model_id:
model = await db.get(AiModel, body.model_id)
elif paragraph.model_id:
model = await db.get(AiModel, paragraph.model_id)
file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or [])
file_summaries = (
await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping)
if body.file_paths
else []
)
if model is None or model.status != "enabled":
content = build_mock_content(paragraph)
message = "当前未找到可用模型,返回本地模拟生成结果。"
else:
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
result = await call_ai(paragraph, model, file_summaries)
content = result.content
message = f"已通过模型 {result.used_model} 生成。"
return Response(
data={
"paragraph_id": paragraph.id,
"content": content,
"message": message,
"file_summaries": file_summaries,
}
)
@router.post("/test-stream")
async def generate_test_stream(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
if paragraph is None or paragraph.template_id != body.template_id:
raise HTTPException(status_code=404, detail="段落不存在")
if body.prompt_text:
paragraph.prompt_text = body.prompt_text
model = None
if body.model_id:
model = await db.get(AiModel, body.model_id)
elif paragraph.model_id:
model = await db.get(AiModel, paragraph.model_id)
if model is None or model.status != "enabled":
raise HTTPException(status_code=400, detail="当前段落未配置可用的流式模型")
if not model.supports_streaming:
raise HTTPException(status_code=400, detail="当前模型未开启流式传输")
file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or [])
file_summaries = (
await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping)
if body.file_paths
else []
)
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
async def event_stream():
yield {
"event": "message",
"data": json.dumps(
{
"type": "meta",
"message": f"正在通过模型 {model.name} 流式生成...",
"file_summaries": file_summaries,
},
ensure_ascii=False,
),
}
try:
async for chunk in stream_ai_preview(paragraph, model, file_summaries):
yield {
"event": "message",
"data": json.dumps({"type": "delta", "content": chunk}, ensure_ascii=False),
}
yield {
"event": "message",
"data": json.dumps({"type": "done"}, ensure_ascii=False),
}
except Exception as error:
fallback_message = str(error)
if "503" in fallback_message or "temporarily unavailable" in fallback_message.lower():
try:
result = await call_ai(paragraph, model, file_summaries)
yield {
"event": "message",
"data": json.dumps(
{
"type": "meta",
"message": "流式通道暂时不可用,已自动回退为普通返回。",
"file_summaries": file_summaries,
},
ensure_ascii=False,
),
}
yield {
"event": "message",
"data": json.dumps({"type": "delta", "content": result.raw_text}, ensure_ascii=False),
}
yield {
"event": "message",
"data": json.dumps({"type": "done"}, ensure_ascii=False),
}
return
except Exception as fallback_error:
fallback_message = f"{fallback_message};普通调用回退也失败:{fallback_error}"
yield {
"event": "message",
"data": json.dumps({"type": "error", "message": fallback_message}, ensure_ascii=False),
}
return EventSourceResponse(
event_stream(),
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/upload")
async def upload_reference_file(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
ext = os.path.splitext(file.filename)[1].lower()
if ext not in settings.ALLOWED_EXTENSIONS:
allowed = " / ".join(settings.ALLOWED_EXTENSIONS)
raise HTTPException(status_code=400, detail=f"文件类型不支持:{ext or '无扩展名'}。当前支持:{allowed}")
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="文件大小超过限制")
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}{ext}"
await asyncio.to_thread(
upload_bytes,
settings.MINIO_BUCKET_UPLOADS,
object_name,
content,
file.content_type or "application/octet-stream",
)
record = ReferenceFile(
file_name=file.filename,
file_path=f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}",
file_size=len(content),
content_type=file.content_type or "application/octet-stream",
)
db.add(record)
await db.commit()
await db.refresh(record)
return Response(
data=_serialize_reference_file(record)
)
@router.get("/reference-files")
async def list_reference_files(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str = Query("", description="按文件名搜索"),
db: AsyncSession = Depends(get_db),
):
stmt = select(ReferenceFile)
count_stmt = select(func.count(ReferenceFile.id))
if keyword:
like_keyword = f"%{keyword.strip()}%"
stmt = stmt.where(ReferenceFile.file_name.like(like_keyword))
count_stmt = count_stmt.where(ReferenceFile.file_name.like(like_keyword))
total = (await db.execute(count_stmt)).scalar_one()
result = await db.execute(
stmt.order_by(ReferenceFile.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
items = [_serialize_reference_file(item) for item in result.scalars().all()]
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
@router.get("/reference-files/{file_id}/download")
async def download_reference_file(file_id: int, db: AsyncSession = Depends(get_db)):
record = await db.get(ReferenceFile, file_id)
if record is None:
raise HTTPException(status_code=404, detail="附件记录不存在")
bucket, object_name = split_bucket_path(record.file_path)
return Response(data={"url": get_presigned_url(bucket, object_name), "file_name": record.file_name})
@router.put("/reference-files/{file_id}")
async def update_reference_file(file_id: int, body: ReferenceFileUpdate, db: AsyncSession = Depends(get_db)):
record = await db.get(ReferenceFile, file_id)
if record is None:
raise HTTPException(status_code=404, detail="附件记录不存在")
record.file_name = body.file_name.strip()
await db.commit()
await db.refresh(record)
return Response(data=_serialize_reference_file(record))
@router.delete("/reference-files/{file_id}")
async def delete_reference_file(file_id: int, db: AsyncSession = Depends(get_db)):
record = await db.get(ReferenceFile, file_id)
if record is None:
raise HTTPException(status_code=404, detail="附件记录不存在")
try:
bucket, object_name = split_bucket_path(record.file_path)
await asyncio.to_thread(delete_object, bucket, object_name)
except Exception:
pass
await db.delete(record)
await db.commit()
return Response(data={"id": file_id})
@router.post("/full")
async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)):
template = await db.get(Template, body.template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == body.template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
paragraphs = result.scalars().all()
if not paragraphs:
raise HTTPException(status_code=400, detail="模板下暂无可生成段落")
normalized_file_map: dict[str, list[str]] = {}
for key, values in (body.file_map or {}).items():
if not isinstance(values, list):
continue
normalized_file_map[str(key)] = [item for item in values if item]
all_file_paths = [item for values in normalized_file_map.values() for item in values]
file_records = await _build_reference_records_mapping(db, all_file_paths)
paragraph_snapshot = []
for paragraph in paragraphs:
selected_paths = normalized_file_map.get(str(paragraph.id), [])
paragraph_snapshot.append(
{
"paragraph_id": paragraph.id,
"title": paragraph.title,
"sort_index": paragraph.sort_index,
"need_file": bool(paragraph.need_file),
"file_note": paragraph.file_note or "",
"selected_files": [
_serialize_reference_file(file_records[file_path])
for file_path in selected_paths
if file_path in file_records
],
}
)
request_payload = {
"template_name": template.name,
"file_map": normalized_file_map,
"paragraphs": paragraph_snapshot,
}
document = Document(
template_id=template.id,
name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
para_count_done=0,
para_count_total=len(paragraphs),
status="generating",
file_path="",
error="",
request_payload_json=json.dumps(request_payload, ensure_ascii=False),
)
db.add(document)
await db.flush()
await db.commit()
await db.refresh(document)
update_progress(document.id, status="pending", percent=0, done=0, total=len(paragraphs), message="任务已创建")
asyncio.create_task(run_generation(document.id, template.id))
return Response(data=_serialize_document(document))
@router.get("/progress/{document_id}")
async def generate_progress(document_id: int):
async def event_generator():
while True:
state = generation_progress.get(
document_id,
{"status": "pending", "percent": 0, "message": "等待中", "done": 0, "total": 0},
)
yield {
"event": "progress",
"data": json.dumps(state, ensure_ascii=False),
}
if state.get("status") in {"completed", "failed", "cancelled"}:
break
await asyncio.sleep(1)
return EventSourceResponse(event_generator())
@router.get("/documents")
async def list_documents(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
total = (await db.execute(select(func.count(Document.id)))).scalar_one()
result = await db.execute(
select(Document)
.order_by(Document.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [_serialize_document(item) for item in result.scalars().all()]
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
@router.get("/documents/{document_id}")
async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
log_result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
items = []
for log, paragraph in log_result.all():
items.append(
{
"id": log.id,
"paragraph_id": paragraph.id,
"title": paragraph.title,
"sort_index": paragraph.sort_index,
"status": log.status,
"content": json.loads(log.content) if log.content else {"content": []},
}
)
payload = _serialize_document(document)
payload["logs"] = items
return Response(data=payload)
@router.post("/cancel/{document_id}")
async def cancel_document(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
if document.status in {"completed", "failed", "cancelled"}:
return Response(data=_serialize_document(document))
request_cancel(document_id)
return Response(data=_serialize_document(document))
@router.delete("/documents/{document_id}")
async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
result = await db.execute(select(GenerationLog).where(GenerationLog.document_id == document_id))
for log in result.scalars().all():
await db.delete(log)
await db.delete(document)
await db.commit()
return Response(data={"id": document_id})
+162
View File
@@ -0,0 +1,162 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import httpx
from database import get_db
from models.ai_model import AiModel
from schemas.schemas import AiModelCreate, AiModelUpdate, Response
from services.ai_service import call_ai
from services.security import decrypt_text, encrypt_text, mask_secret
router = APIRouter()
def _is_deepseek_model(model: AiModel) -> bool:
provider = (model.provider or "").strip().lower()
return provider == "deepseek"
def _serialize_model(model: AiModel) -> dict:
api_key = decrypt_text(model.api_key_encrypted)
return {
"id": model.id,
"name": model.name,
"provider": model.provider,
"api_format": model.api_format,
"api_endpoint": model.api_endpoint,
"api_key_preview": mask_secret(api_key),
"supports_streaming": bool(model.supports_streaming),
"enable_reasoning": bool(model.enable_reasoning),
"status": model.status,
"created_at": model.created_at,
}
@router.get("")
async def list_models(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AiModel).order_by(AiModel.id.desc()))
items = [_serialize_model(item) for item in result.scalars().all()]
return Response(data=items)
@router.post("")
async def create_model(body: AiModelCreate, db: AsyncSession = Depends(get_db)):
model = AiModel(
name=body.name,
provider=body.provider,
api_format=body.api_format,
api_endpoint=body.api_endpoint,
api_key_encrypted=encrypt_text(body.api_key),
supports_streaming=body.supports_streaming,
enable_reasoning=body.enable_reasoning,
status=body.status,
)
db.add(model)
await db.commit()
await db.refresh(model)
return Response(data=_serialize_model(model))
@router.put("/{model_id}")
async def update_model(model_id: int, body: AiModelUpdate, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
if body.name is not None:
model.name = body.name
if body.provider is not None:
model.provider = body.provider
if body.api_format is not None:
model.api_format = body.api_format
if body.api_endpoint is not None:
model.api_endpoint = body.api_endpoint
if body.supports_streaming is not None:
model.supports_streaming = body.supports_streaming
if body.enable_reasoning is not None:
model.enable_reasoning = body.enable_reasoning
if body.status is not None:
model.status = body.status
if body.api_key:
model.api_key_encrypted = encrypt_text(body.api_key)
await db.commit()
await db.refresh(model)
return Response(data=_serialize_model(model))
@router.delete("/{model_id}")
async def delete_model(model_id: int, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
await db.delete(model)
await db.commit()
return Response(data={"id": model_id})
@router.post("/{model_id}/test")
async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
class FakeParagraph:
title = "连接测试"
content = "请返回一段非常简短的测试文本。"
need_prompt = False
prompt_text = ""
output_format = "text"
enable_reasoning = bool(model.enable_reasoning)
try:
result = await call_ai(FakeParagraph(), model)
return Response(
data={
"id": model.id,
"success": True,
"message": f"模型 {model.name} 连接测试成功",
"preview": result.content,
}
)
except Exception as error:
return Response(
code=-1,
message=str(error),
data={"id": model.id, "success": False},
)
@router.get("/{model_id}/balance")
async def get_model_balance(model_id: int, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
if not _is_deepseek_model(model):
raise HTTPException(status_code=400, detail="仅 DeepSeek 模型支持余额查询")
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise HTTPException(status_code=400, detail="模型 API Key 不可用")
try:
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
response = await client.get(
"https://api.deepseek.com/user/balance",
headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
)
response.raise_for_status()
payload = response.json()
except Exception as error:
raise HTTPException(status_code=400, detail=f"查询余额失败:{error}")
return Response(
data={
"id": model.id,
"provider": model.provider,
"is_available": payload.get("is_available", False),
"balance_infos": payload.get("balance_infos", []),
}
)
+573
View File
@@ -0,0 +1,573 @@
import asyncio
import json
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 delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from models.template_block import TemplateBlock
from schemas.schemas import Response, TemplateSave
from services.document_export import export_document_bytes
from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes
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,
"anchor_title": paragraph.anchor_title,
"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,
"write_mode": paragraph.write_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,
}
def _build_block_from_paragraph(paragraph: Paragraph) -> dict:
block_type = "heading" if paragraph.write_mode == "replace_heading_only" else ("ai_slot" if paragraph.edit_mode == "ai" else ("table" if paragraph.is_table else "text"))
content_json = json.dumps({
"text": paragraph.content or "",
"table": json.loads(paragraph.table_json or "{}") if paragraph.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": paragraph.id,
"parent_block_id": None,
"sort_index": paragraph.sort_index,
"block_type": block_type,
"anchor_ref": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"content_json": content_json,
"style_json": paragraph.style_json or "{}",
"edit_mode": paragraph.edit_mode,
"placeholder_key": "",
"variable_key": "",
"default_value": paragraph.content or "",
"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 _build_block_from_parsed_item(item, source_paragraph_id: int | None) -> dict:
content_json = json.dumps({
"text": item.content or "",
"table": json.loads(item.table_json or "{}") if item.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": source_paragraph_id,
"parent_block_id": None,
"sort_index": item.sort_index,
"block_type": item.block_type,
"anchor_ref": item.anchor_title or item.title,
"title": item.title,
"content_json": content_json,
"style_json": item.style_json or "{}",
"edit_mode": item.edit_mode,
"placeholder_key": item.placeholder_key,
"variable_key": item.variable_key,
"default_value": item.default_value,
"model_id": None,
"need_prompt": True,
"prompt_text": "",
"need_file": False,
"file_note": "",
"output_format": item.output_format,
}
def _serialize_block(block: TemplateBlock) -> dict:
try:
content_json = json.loads(block.content_json or "{}")
except Exception:
content_json = {}
return {
"id": block.id,
"template_id": block.template_id,
"source_paragraph_id": block.source_paragraph_id,
"parent_block_id": block.parent_block_id,
"sort_index": block.sort_index,
"block_type": block.block_type,
"anchor_ref": block.anchor_ref,
"title": block.title,
"content_json": content_json,
"style_json": block.style_json,
"edit_mode": block.edit_mode,
"placeholder_key": block.placeholder_key,
"variable_key": block.variable_key,
"default_value": block.default_value,
"model_id": block.model_id,
"need_prompt": block.need_prompt,
"prompt_text": block.prompt_text,
"need_file": block.need_file,
"file_note": block.file_note,
"output_format": block.output_format,
}
async def _load_blocks(db: AsyncSession, template_id: int) -> list[TemplateBlock]:
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
return result.scalars().all()
async def _sync_blocks_from_paragraphs(db: AsyncSession, template_id: int, paragraphs: list[Paragraph]):
existing_blocks = await _load_blocks(db, template_id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for paragraph in paragraphs:
block = TemplateBlock(template_id=template_id, **_build_block_from_paragraph(paragraph))
db.add(block)
block_rows.append(block)
await db.flush()
return block_rows
async def _save_blocks(
db: AsyncSession,
template_id: int,
blocks_payload,
):
existing_blocks = await _load_blocks(db, template_id)
block_map = {item.id: item for item in existing_blocks}
incoming_ids = {config.id for config in blocks_payload if config.id}
for block in existing_blocks:
if block.id not in incoming_ids:
await db.delete(block)
for index, config in enumerate(blocks_payload, start=1):
block = block_map.get(config.id) if config.id else None
if block is None:
block = TemplateBlock(template_id=template_id)
db.add(block)
block.source_paragraph_id = config.source_paragraph_id
block.parent_block_id = config.parent_block_id
block.sort_index = index
block.block_type = config.block_type
block.anchor_ref = config.anchor_ref or config.title
block.title = config.title
block.content_json = json.dumps(config.content_json or {}, ensure_ascii=False)
block.style_json = config.style_json or "{}"
block.edit_mode = config.edit_mode
block.placeholder_key = config.placeholder_key
block.variable_key = config.variable_key
block.default_value = config.default_value
block.model_id = config.model_id
block.need_prompt = config.need_prompt
block.prompt_text = config.prompt_text
block.need_file = config.need_file
block.file_note = config.file_note
block.output_format = config.output_format
await db.flush()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
async def _sync_paragraphs_from_blocks(db: AsyncSession, template_id: int) -> list[Paragraph]:
paragraph_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
existing_paragraphs = paragraph_result.scalars().all()
paragraph_map = {item.id: item for item in existing_paragraphs}
blocks = await _load_blocks(db, template_id)
write_modes = _resolve_block_write_modes(blocks)
kept_paragraph_ids: set[int] = set()
synced_rows: list[Paragraph] = []
for index, (block, write_mode) in enumerate(zip(blocks, write_modes), start=1):
paragraph = paragraph_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
if paragraph is None:
paragraph = Paragraph(template_id=template_id)
db.add(paragraph)
await db.flush()
paragraph.sort_index = index
paragraph.anchor_title = block.anchor_ref or block.title
paragraph.title = block.title
paragraph.content = _block_text_content(block)
paragraph.style_json = block.style_json or "{}"
paragraph.is_table = block.block_type == "table"
paragraph.table_json = json.dumps(_block_table_content(block), ensure_ascii=False) if paragraph.is_table else "{}"
paragraph.edit_mode = "ai" if block.block_type == "ai_slot" or block.edit_mode == "ai" else "manual"
paragraph.write_mode = write_mode
paragraph.model_id = block.model_id
paragraph.need_prompt = block.need_prompt
paragraph.prompt_text = block.prompt_text
paragraph.need_file = block.need_file
paragraph.file_note = block.file_note
paragraph.output_format = block.output_format
block.source_paragraph_id = paragraph.id
kept_paragraph_ids.add(paragraph.id)
synced_rows.append(paragraph)
for paragraph in existing_paragraphs:
if paragraph.id in kept_paragraph_ids:
continue
await db.execute(delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id))
await db.delete(paragraph)
await db.flush()
return synced_rows
def _build_export_content_from_block(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
async def _write_template_snapshot_to_docx(db: AsyncSession, template: Template):
blocks = await _load_blocks(db, template.id)
if not blocks:
return
write_modes = _resolve_block_write_modes(blocks)
logs = []
for block, write_mode in zip(blocks, write_modes):
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": _build_export_content_from_block(block),
}
)
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
await asyncio.to_thread(
upload_bytes,
template_bucket,
template_object,
exported_bytes,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
@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())
)
paragraph_rows = result.scalars().all()
paragraphs = [_serialize_paragraph(item) for item in paragraph_rows]
block_rows = await _load_blocks(db, template_id)
if not block_rows and paragraph_rows:
block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows)
await db.commit()
blocks = [_serialize_block(item) for item in block_rows]
payload = _serialize_template(template)
payload["paragraphs"] = paragraphs
payload["blocks"] = blocks
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,
anchor_title=item.anchor_title,
title=item.title,
content=item.content,
style_json=item.style_json,
is_table=item.is_table,
table_json=item.table_json,
edit_mode=item.edit_mode,
write_mode=item.write_mode,
need_prompt=item.edit_mode == "ai",
output_format=item.output_format,
)
db.add(paragraph)
paragraph_rows.append(paragraph)
await db.flush()
existing_blocks = await _load_blocks(db, template.id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for item, paragraph in zip(parsed_items, paragraph_rows):
block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id))
db.add(block)
block_rows.append(block)
await db.flush()
await db.commit()
await db.refresh(template)
for paragraph in paragraph_rows:
await db.refresh(paragraph)
for block in block_rows:
await db.refresh(block)
payload = _serialize_template(template)
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
payload["blocks"] = [_serialize_block(item) for item in block_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)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
existing_paragraphs = result.scalars().all()
paragraph_map = {item.id: item for item in existing_paragraphs}
incoming_ids = {config.id for config in body.paragraphs if config.id}
print(f"[SAVE] template_id={template_id}, incoming_ids={incoming_ids}, existing_ids={[p.id for p in existing_paragraphs]}")
for paragraph in existing_paragraphs:
if paragraph.id not in incoming_ids:
print(f"[SAVE] Deleting paragraph id={paragraph.id} title={paragraph.title}")
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id)
)
await db.delete(paragraph)
for index, config in enumerate(body.paragraphs, start=1):
paragraph = paragraph_map.get(config.id) if config.id else None
if paragraph is None:
paragraph = Paragraph(template_id=template_id)
db.add(paragraph)
paragraph.sort_index = index
paragraph.anchor_title = config.anchor_title or config.title or paragraph.anchor_title
paragraph.title = config.title
paragraph.content = config.content
paragraph.edit_mode = config.edit_mode
paragraph.write_mode = config.write_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.flush()
refreshed_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
refreshed_paragraphs = refreshed_result.scalars().all()
if body.save_mode == "manual" and body.blocks:
await _save_blocks(db, template_id, body.blocks)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
else:
await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
template.paragraph_count = len(refreshed_paragraphs)
await _write_template_snapshot_to_docx(db, template)
await db.commit()
blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)]
return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks})
@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))
paragraphs_to_delete = result.scalars().all()
paragraph_ids = [p.id for p in paragraphs_to_delete]
doc_result = await db.execute(select(Document).where(Document.template_id == template_id))
documents_to_delete = doc_result.scalars().all()
if paragraph_ids:
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id.in_(paragraph_ids))
)
for document in documents_to_delete:
await db.execute(
delete(GenerationLog).where(GenerationLog.document_id == document.id)
)
await db.delete(document)
for paragraph in paragraphs_to_delete:
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})
View File
+132
View File
@@ -0,0 +1,132 @@
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
anchor_title: str = ""
title: str = ""
content: str = ""
edit_mode: str = "manual"
write_mode: str = "replace_section"
model_id: Optional[int] = None
need_prompt: bool = True
prompt_text: str = ""
need_file: bool = False
file_note: str = ""
output_format: str = "text"
class TemplateBlockConfig(BaseModel):
id: int = 0
source_paragraph_id: Optional[int] = None
parent_block_id: Optional[int] = None
sort_index: int = 0
block_type: str = "text"
anchor_ref: str = ""
title: str = ""
content_json: dict[str, Any] = Field(default_factory=dict)
style_json: str = "{}"
edit_mode: str = "manual"
placeholder_key: str = ""
variable_key: str = ""
default_value: str = ""
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):
save_mode: str = "paragraph"
paragraphs: list[ParagraphConfig] = []
blocks: list[TemplateBlockConfig] = []
# 模型
class AiModelCreate(BaseModel):
name: str
provider: str = ""
api_format: str = "openai"
api_endpoint: str = ""
api_key: str = ""
supports_streaming: bool = False
enable_reasoning: bool = False
status: str = "enabled"
class AiModelUpdate(BaseModel):
name: Optional[str] = None
provider: Optional[str] = None
api_format: Optional[str] = None
api_endpoint: Optional[str] = None
api_key: str = ""
supports_streaming: Optional[bool] = None
enable_reasoning: Optional[bool] = None
status: Optional[str] = None
class AiModelOut(BaseModel):
id: int
name: str
provider: str = ""
api_format: str = "openai"
api_endpoint: str = ""
api_key_preview: str = ""
supports_streaming: bool = False
enable_reasoning: bool = False
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, list[str]] = {} # paragraph_id -> file_paths
class ReferenceFileUpdate(BaseModel):
file_name: str = Field(min_length=1, max_length=255)
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
+307
View File
@@ -0,0 +1,307 @@
import asyncio
import json
import re
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
from config import settings
from models.ai_model import AiModel
from models.paragraph import Paragraph
from services.security import decrypt_text
@dataclass
class AiCallResult:
content: dict
raw_text: str
used_model: str
def _ensure_json_content(text: str) -> dict:
stripped = text.strip()
if not stripped:
return {"content": [{"type": "text", "text": ""}]}
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict) and "content" in parsed:
return parsed
except json.JSONDecodeError:
pass
code_block_match = re.search(r"```json\s*(.*?)\s*```", stripped, re.S)
if code_block_match:
try:
parsed = json.loads(code_block_match.group(1))
if isinstance(parsed, dict) and "content" in parsed:
return parsed
except json.JSONDecodeError:
pass
return {"content": [{"type": "text", "text": stripped}]}
def _format_file_context(file_summaries: list[dict]) -> str:
file_blocks: list[str] = []
for item in file_summaries:
file_name = item.get("file_name") or "未命名文件"
summary = item.get("summary") or "文件内容为空。"
file_blocks.append(f"文件:{file_name}\n内容:\n{summary}")
return "\n\n".join(file_blocks)
def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]:
system_prompt = (
"你是一个企业文档撰写助手。"
"请严格输出 JSON,不要输出 JSON 之外的说明。"
'格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。'
)
if getattr(paragraph, "enable_reasoning", False):
system_prompt += "你可以先进行充分思考,再给出最终答案,但最终只输出要求的结果内容。"
user_parts = [f"段落标题:{paragraph.title}"]
if paragraph.content:
user_parts.append(f"模板上下文:{paragraph.content}")
if paragraph.need_prompt and paragraph.prompt_text:
user_parts.append(f"附加要求:{paragraph.prompt_text}")
if file_summaries:
user_parts.append("参考文件内容:\n" + _format_file_context(file_summaries))
user_parts.append(f"输出格式:{paragraph.output_format}")
return system_prompt, "\n\n".join(user_parts)
def _normalize_openai_endpoint(api_endpoint: str) -> str:
endpoint = api_endpoint.rstrip("/")
parsed = urlparse(endpoint if "://" in endpoint else f"https://{endpoint}")
host = parsed.netloc or parsed.path.split("/")[0]
if host == "api.deepseek.com":
return "https://api.deepseek.com/chat/completions"
if endpoint.endswith("/chat/completions"):
return endpoint
if endpoint.endswith("/v1"):
return f"{endpoint}/chat/completions"
return f"{endpoint}/v1/chat/completions"
def _normalize_anthropic_endpoint(api_endpoint: str) -> str:
endpoint = api_endpoint.rstrip("/")
if endpoint.endswith("/messages"):
return endpoint
if endpoint.endswith("/v1"):
return f"{endpoint}/messages"
return f"{endpoint}/v1/messages"
async def _post_with_retry(
client: httpx.AsyncClient,
url: str,
headers: dict,
payload: dict,
) -> httpx.Response:
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
response = await client.post(url, headers=headers, json=payload)
if response.status_code in (429, 500, 502, 503, 504):
raise httpx.HTTPStatusError(
f"上游模型响应异常: {response.status_code} - {response.text[:500]}",
request=response.request,
response=response,
)
response.raise_for_status()
return response
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error:
last_error = error
if attempt == settings.AI_MAX_RETRIES - 1:
break
await asyncio.sleep(2 ** attempt)
error_message = str(last_error)
if isinstance(last_error, httpx.HTTPStatusError) and last_error.response is not None:
error_message = f"{error_message}\n响应内容: {last_error.response.text[:1000]}"
raise RuntimeError(f"模型调用失败:{error_message}")
async def _call_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
response = await _post_with_retry(client, _normalize_openai_endpoint(model.api_endpoint), headers, payload)
body = response.json()
text = body["choices"][0]["message"]["content"]
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def _stream_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str):
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
"stream": True,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
async with client.stream("POST", _normalize_openai_endpoint(model.api_endpoint), headers=headers, json=payload) as response:
if response.status_code in (429, 500, 502, 503, 504):
body = await response.aread()
raise httpx.HTTPStatusError(
f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}",
request=response.request,
response=response,
)
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
payload_line = line[5:].strip()
if payload_line == "[DONE]":
break
try:
chunk = json.loads(payload_line)
except json.JSONDecodeError:
continue
delta_payload = chunk.get("choices", [{}])[0].get("delta", {})
delta = delta_payload.get("content", "")
reasoning = delta_payload.get("reasoning_content", "")
if isinstance(delta, list):
delta = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in delta
)
if delta:
yield delta
if reasoning:
yield reasoning
return
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error:
last_error = error
if attempt == settings.AI_MAX_RETRIES - 1:
break
await asyncio.sleep(2 ** attempt)
raise RuntimeError(f"模型流式调用失败:{last_error}")
async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"max_tokens": 2048,
"system": system_prompt,
"messages": [{"role": "user", "content": user_prompt}],
}
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
response = await _post_with_retry(client, _normalize_anthropic_endpoint(model.api_endpoint), headers, payload)
body = response.json()
text = ""
for item in body.get("content", []):
if item.get("type") == "text":
text += item.get("text", "")
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def _stream_anthropic(model: AiModel, system_prompt: str, user_prompt: str):
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"max_tokens": 2048,
"system": system_prompt,
"messages": [{"role": "user", "content": user_prompt}],
"stream": True,
}
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
async with client.stream("POST", _normalize_anthropic_endpoint(model.api_endpoint), headers=headers, json=payload) as response:
if response.status_code in (429, 500, 502, 503, 504):
body = await response.aread()
raise httpx.HTTPStatusError(
f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}",
request=response.request,
response=response,
)
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
payload_line = line[5:].strip()
if payload_line == "[DONE]":
break
try:
chunk = json.loads(payload_line)
except json.JSONDecodeError:
continue
if chunk.get("type") == "content_block_delta":
delta = chunk.get("delta", {}).get("text", "")
if delta:
yield delta
return
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error:
last_error = error
if attempt == settings.AI_MAX_RETRIES - 1:
break
await asyncio.sleep(2 ** attempt)
raise RuntimeError(f"模型流式调用失败:{last_error}")
async def call_ai(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None) -> AiCallResult:
system_prompt, user_prompt = _build_prompt(paragraph, file_summaries)
if model.api_format == "anthropic":
return await _call_anthropic(model, system_prompt, user_prompt)
return await _call_openai_compatible(model, system_prompt, user_prompt)
def build_test_stream_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]:
system_prompt = "你是一个企业文档撰写助手。请直接输出适合预览的正文内容或 Markdown 表格,不要输出 JSON。"
if getattr(paragraph, "enable_reasoning", False):
system_prompt += "你可以先进行充分思考,再持续输出最终可展示的内容。"
_, user_prompt = _build_prompt(paragraph, file_summaries or [])
return system_prompt, user_prompt
async def stream_ai_preview(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None):
system_prompt, user_prompt = build_test_stream_prompt(paragraph, file_summaries)
if model.api_format == "anthropic":
async for chunk in _stream_anthropic(model, system_prompt, user_prompt):
yield chunk
return
async for chunk in _stream_openai_compatible(model, system_prompt, user_prompt):
yield chunk
+463
View File
@@ -0,0 +1,463 @@
from io import BytesIO
from copy import deepcopy
from docx import Document
from docx.document import Document as DocumentObject
from docx.oxml import OxmlElement
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table, _Cell
from docx.text.paragraph import Paragraph
def _iter_block_items(parent: DocumentObject | _Cell):
parent_elm = parent.element.body if isinstance(parent, DocumentObject) else parent._tc
for child in parent_elm.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield Table(child, parent)
def _is_heading(paragraph: Paragraph) -> bool:
style_name = paragraph.style.name if paragraph.style is not None else ""
normalized = style_name.lower().replace(" ", "")
return normalized.startswith("heading")
def _delete_block(block):
element = block._element
parent = element.getparent()
if parent is not None:
parent.remove(element)
def _delete_heading_section(heading: Paragraph):
blocks = [heading]
current = heading._element.getnext()
while current is not None:
if isinstance(current, CT_P):
para = Paragraph(current, heading._parent)
if _is_heading(para):
break
blocks.append(para)
elif isinstance(current, CT_Tbl):
blocks.append(Table(current, heading._parent))
current = current.getnext()
for block in blocks:
_delete_block(block)
def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors: set[str]):
headings_to_remove: list[Paragraph] = []
found_first_heading = False
pre_heading_blocks: list = []
print(f"[EXPORT] referenced_anchors: {referenced_anchors}")
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block):
found_first_heading = True
text = block.text.strip()
if text not in referenced_anchors:
print(f"[EXPORT] Unreferenced heading found, will remove: '{text}'")
headings_to_remove.append(block)
elif not found_first_heading:
pre_heading_blocks.append(block)
for heading in headings_to_remove:
_delete_heading_section(heading)
if not referenced_anchors:
for block in pre_heading_blocks:
_delete_block(block)
def _ordered_unique_anchors(logs: list[dict]) -> list[str]:
ordered: list[str] = []
seen: set[str] = set()
for item in logs:
anchor = (item.get("anchor_title") or item.get("title") or "").strip()
if not anchor or anchor in seen:
continue
seen.add(anchor)
ordered.append(anchor)
return ordered
def _reorder_heading_sections(document: DocumentObject, ordered_anchors: list[str]):
body = document.element.body
elements = list(body.iterchildren())
pre_heading: list = []
sections: list[tuple[str, list]] = []
found_heading = False
index = 0
while index < len(elements):
child = elements[index]
if isinstance(child, CT_P):
paragraph = Paragraph(child, document)
if _is_heading(paragraph):
found_heading = True
anchor = paragraph.text.strip()
section_elements = [child]
index += 1
while index < len(elements):
current = elements[index]
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, document)
if _is_heading(current_paragraph):
break
section_elements.append(current)
index += 1
sections.append((anchor, section_elements))
continue
if not found_heading:
pre_heading.append(child)
index += 1
if not sections:
return
section_map: dict[str, list[list]] = {}
for anchor, section_elements in sections:
section_map.setdefault(anchor, []).append(section_elements)
all_section_elements = [element for _, section_elements in sections for element in section_elements]
for element in all_section_elements:
parent = element.getparent()
if parent is not None:
parent.remove(element)
sect_pr = None
for child in list(body.iterchildren()):
if not isinstance(child, (CT_P, CT_Tbl)):
sect_pr = child
break
for anchor in ordered_anchors:
for section_elements in section_map.pop(anchor, []):
for element in section_elements:
if sect_pr is not None:
sect_pr.addprevious(element)
else:
body.append(element)
def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element
for child in list(element):
if child.tag.endswith("}r"):
element.remove(child)
def _copy_paragraph_format(target: Paragraph, source: Paragraph | None):
if source is None:
return
source_ppr = source._element.pPr
if source_ppr is not None:
target._element.insert(0, deepcopy(source_ppr))
def _copy_run_format(target_run, source_paragraph: Paragraph | None):
if source_paragraph is None:
return
for source_run in source_paragraph.runs:
if source_run._element.rPr is not None:
target_run._element.insert(0, deepcopy(source_run._element.rPr))
break
def _extract_first_run_format(source_paragraph: Paragraph | None):
if source_paragraph is None:
return None
for source_run in source_paragraph.runs:
if source_run._element.rPr is not None:
return deepcopy(source_run._element.rPr)
return None
def _set_paragraph_text(
paragraph: Paragraph,
text: str,
style_name: str | None = None,
template_paragraph: Paragraph | None = None,
):
run_format = _extract_first_run_format(template_paragraph)
_clear_paragraph(paragraph)
if style_name:
try:
paragraph.style = style_name
except Exception:
pass
if text:
run = paragraph.add_run(text)
if run_format is not None:
run._element.insert(0, run_format)
def _append_paragraph_after(
paragraph: Paragraph,
text: str,
style_name: str | None = None,
template_paragraph: Paragraph | None = None,
) -> Paragraph:
new_p = OxmlElement("w:p")
paragraph._element.addnext(new_p)
new_para = Paragraph(new_p, paragraph._parent)
_copy_paragraph_format(new_para, template_paragraph)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
if text:
run = new_para.add_run(text)
_copy_run_format(run, template_paragraph)
return new_para
def _set_cell_text_with_template(cell, value: str, template_paragraph: Paragraph | None = None):
if not cell.paragraphs:
cell.text = value
return
paragraph = cell.paragraphs[0]
_clear_paragraph(paragraph)
run = paragraph.add_run(value)
_copy_run_format(run, template_paragraph)
def _resize_table_rows(table: Table, row_count: int):
current_rows = len(table.rows)
if current_rows == 0:
return
if current_rows < row_count:
template_row = table.rows[-1]._tr
for _ in range(row_count - current_rows):
table._tbl.append(deepcopy(template_row))
elif current_rows > row_count:
for _ in range(current_rows - row_count):
table._tbl.remove(table.rows[-1]._tr)
def _fill_table(table: Table, matrix: list[list[str]]):
if not matrix:
return
_resize_table_rows(table, len(matrix))
template_cell_paragraph = table.rows[0].cells[0].paragraphs[0] if table.rows and table.rows[0].cells else None
for row_index, row_values in enumerate(matrix):
row = table.rows[row_index]
for col_index, cell in enumerate(row.cells):
value = row_values[col_index] if col_index < len(row_values) else ""
_set_cell_text_with_template(cell, value, template_cell_paragraph)
def _append_table_after(
paragraph: Paragraph,
rows: list[list[str]],
headers: list[str] | None = None,
template_table: Table | None = None,
):
matrix = [headers, *rows] if headers else rows
if template_table is not None:
cloned_tbl = deepcopy(template_table._tbl)
paragraph._element.addnext(cloned_tbl)
cloned_table = Table(cloned_tbl, paragraph._parent)
_fill_table(cloned_table, matrix)
return cloned_table
container = paragraph._parent
table = container.add_table(rows=max(len(matrix), 1), cols=max(len(headers or []), len(rows[0]) if rows else 1))
if headers:
for row_index, row_values in enumerate(matrix):
for index, value in enumerate(row_values):
table.rows[row_index].cells[index].text = value
elif rows:
for row_index, row_values in enumerate(matrix):
for index, value in enumerate(row_values):
table.rows[row_index].cells[index].text = value
tbl = table._tbl
tbl.getparent().remove(tbl)
paragraph._element.addnext(tbl)
return Table(tbl, container)
def _append_empty_paragraph_after_table(table: Table, style_name: str | None = None) -> Paragraph:
new_p = OxmlElement("w:p")
table._tbl.addnext(new_p)
new_para = Paragraph(new_p, table._parent)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
return new_para
def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_element=None) -> Paragraph | None:
started = after_element is None
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
if started:
return block
if after_element is not None and block._element == after_element:
started = True
return None
def _collect_section_templates(heading: Paragraph):
first_body_style = None
paragraph_template = None
table_template = None
blocks = []
current = heading._element.getnext()
while current is not None:
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent)
if _is_heading(current_paragraph):
break
if first_body_style is None and current_paragraph.style is not None:
first_body_style = current_paragraph.style.name
if paragraph_template is None:
paragraph_template = current_paragraph
blocks.append(current_paragraph)
elif isinstance(current, CT_Tbl):
current_table = Table(current, heading._parent)
if table_template is None:
table_template = current_table
blocks.append(current_table)
current = current.getnext()
return first_body_style, paragraph_template, table_template, blocks
def _insert_content_after(
insert_after: Paragraph,
content: dict,
first_body_style: str | None,
paragraph_template: Paragraph | None,
table_template: Table | None,
):
current_anchor: Paragraph = insert_after
content_blocks = content.get("content", [])
for block in content_blocks:
block_type = block.get("type")
if block_type == "table":
rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or []
table = _append_table_after(current_anchor, rows, headers, table_template)
current_anchor = _append_empty_paragraph_after_table(table, first_body_style)
else:
text = block.get("text", "")
text_parts = [item for item in text.split("\n") if item] or [text]
for text_part in text_parts:
current_anchor = _append_paragraph_after(
current_anchor,
text_part,
first_body_style,
paragraph_template,
)
return current_anchor
def _replace_section_content(
document: DocumentObject,
anchor_title: str,
target_title: str,
content: dict,
write_mode: str,
after_element=None,
):
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if write_mode == "replace_heading_only":
return heading._element
if write_mode == "replace_section":
for block in blocks_to_remove:
_delete_block(block)
_insert_content_after(
heading,
content,
first_body_style,
paragraph_template,
table_template,
)
return heading._element
def _group_logs(logs: list[dict]) -> list[list[dict]]:
groups: list[list[dict]] = []
for item in logs:
anchor_title = item.get("anchor_title") or item.get("title") or ""
if not groups:
groups.append([item])
continue
last_group = groups[-1]
last_anchor = last_group[0].get("anchor_title") or last_group[0].get("title") or ""
if anchor_title == last_anchor:
last_group.append(item)
else:
groups.append([item])
return groups
def _replace_section_group(
document: DocumentObject,
items: list[dict],
after_element=None,
):
first_item = items[0]
anchor_title = first_item.get("anchor_title") or first_item.get("title") or ""
target_title = first_item.get("title") or anchor_title
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if len(items) == 1 and first_item.get("write_mode") == "replace_heading_only":
return heading._element
preserve_existing = len(items) == 1 and first_item.get("write_mode") == "append_after_heading"
if not preserve_existing:
for block in blocks_to_remove:
_delete_block(block)
current_anchor = heading
for item in items:
current_anchor = _insert_content_after(
current_anchor,
item.get("content") or {"content": []},
first_body_style,
paragraph_template,
table_template,
)
return heading._element
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
ordered_anchors = _ordered_unique_anchors(logs)
_reorder_heading_sections(document, ordered_anchors)
referenced_anchors: set[str] = set()
for item in logs:
for key in ("anchor_title", "title"):
val = (item.get(key) or "").strip()
if val:
referenced_anchors.add(val)
print(f"[EXPORT] logs count={len(logs)}, anchor_titles={[(l.get('anchor_title'), l.get('title')) for l in logs]}")
last_heading_element = None
for group in _group_logs(logs):
last_heading_element = _replace_section_group(document, group, last_heading_element)
_remove_unreferenced_headings(document, referenced_anchors)
output = BytesIO()
document.save(output)
return output.getvalue()
+110
View File
@@ -0,0 +1,110 @@
import csv
import io
import json
import subprocess
import tempfile
from pathlib import Path
import pandas as pd
from docx import Document
from services.minio_client import download_object_bytes, split_bucket_path
try:
from pypdf import PdfReader
except Exception: # pragma: no cover
PdfReader = None
def _decode_text(content: bytes) -> str:
for encoding in ("utf-8", "utf-8-sig", "gbk", "gb18030"):
try:
return content.decode(encoding)
except Exception:
continue
return content.decode("utf-8", errors="ignore")
def _summarize_docx(content: bytes) -> str:
doc = Document(io.BytesIO(content))
texts = [paragraph.text.strip() for paragraph in doc.paragraphs if paragraph.text.strip()]
return "\n".join(texts[:40])[:4000]
def _summarize_csv(content: bytes) -> str:
text = _decode_text(content)
reader = csv.reader(io.StringIO(text))
rows = list(reader[:20])
return "\n".join([" | ".join(row) for row in rows])[:4000]
def _summarize_excel(content: bytes, suffix: str) -> str:
excel_buffer = io.BytesIO(content)
if suffix in {".xlsx", ".xlsm"}:
sheet_map = pd.read_excel(excel_buffer, sheet_name=None)
else:
sheet_map = pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd")
parts: list[str] = []
for sheet_name, dataframe in list(sheet_map.items())[:5]:
preview = dataframe.head(10).fillna("").astype(str)
parts.append(f"[工作表] {sheet_name}")
parts.append(preview.to_csv(index=False).strip())
return "\n".join(parts)[:5000]
def _summarize_doc(content: bytes) -> str:
with tempfile.NamedTemporaryFile(suffix=".doc") as temp_file:
temp_file.write(content)
temp_file.flush()
result = subprocess.run(
["textutil", "-convert", "txt", "-stdout", temp_file.name],
capture_output=True,
check=False,
)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="ignore").strip()
return f"旧版 Word 文件解析失败:{stderr or 'textutil 无法提取正文'}"
return _decode_text(result.stdout)[:4000]
def _summarize_pdf(content: bytes) -> str:
if PdfReader is None:
return "当前环境未安装 PDF 文本解析依赖,无法提取 PDF 正文。"
reader = PdfReader(io.BytesIO(content))
texts: list[str] = []
for page in reader.pages[:10]:
texts.append((page.extract_text() or "").strip())
return "\n".join(filter(None, texts))[:4000]
def summarize_file_bytes(file_name: str, content: bytes) -> str:
suffix = Path(file_name).suffix.lower()
if suffix in {".txt", ".md", ".json"}:
return _decode_text(content)[:4000]
if suffix == ".csv":
return _summarize_csv(content)
if suffix in {".xlsx", ".xls", ".xlsm"}:
return _summarize_excel(content, suffix)
if suffix == ".docx":
return _summarize_docx(content)
if suffix == ".doc":
return _summarize_doc(content)
if suffix == ".pdf":
return _summarize_pdf(content)
return f"暂不支持解析该文件内容:{file_name}"
def summarize_minio_files(file_paths: list[str], file_name_mapping: dict[str, str] | None = None) -> list[dict]:
summaries: list[dict] = []
for file_path in file_paths:
bucket, object_name = split_bucket_path(file_path)
content = download_object_bytes(bucket, object_name)
file_name = (file_name_mapping or {}).get(file_path) or Path(object_name).name
summaries.append(
{
"file_name": file_name,
"file_path": file_path,
"summary": summarize_file_bytes(file_name, content),
}
)
return summaries
+197
View File
@@ -0,0 +1,197 @@
import asyncio
import json
import time
from datetime import datetime
from sqlalchemy import select
from database import async_session
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from services.ai_service import call_ai
from services.file_summary import summarize_minio_files
generation_progress: dict[int, dict] = {}
generation_cancel_flags: dict[int, bool] = {}
def build_mock_content(paragraph: Paragraph) -> dict:
if paragraph.output_format == "table":
return {
"content": [
{
"type": "table",
"title": paragraph.title,
"headers": ["字段", "内容"],
"rows": [
["段落标题", paragraph.title],
["生成说明", paragraph.prompt_text or "根据模板内容生成"],
],
}
]
}
blocks = [
{
"type": "text",
"text": f"这是“{paragraph.title}”的示例生成内容,可用于前端联调与流程验证。"
}
]
if paragraph.content:
blocks.append({"type": "text", "text": f"模板上下文:{paragraph.content[:200]}"})
if paragraph.need_prompt and paragraph.prompt_text:
blocks.append({"type": "text", "text": f"预设提示词:{paragraph.prompt_text[:200]}"})
return {"content": blocks}
async def get_effective_model(paragraph: Paragraph) -> AiModel | None:
async with async_session() as db:
if paragraph.model_id:
model = await db.get(AiModel, paragraph.model_id)
if model is not None and model.status == "enabled":
return model
result = await db.execute(
select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1)
)
return result.scalars().first()
def update_progress(document_id: int, **kwargs):
state = generation_progress.setdefault(
document_id,
{"percent": 0, "status": "pending", "message": "等待中", "done": 0, "total": 0},
)
state.update(kwargs)
def request_cancel(document_id: int):
generation_cancel_flags[document_id] = True
update_progress(document_id, status="cancelling", message="正在取消...")
def is_cancel_requested(document_id: int) -> bool:
return generation_cancel_flags.get(document_id, False)
async def run_generation(document_id: int, template_id: int):
async with async_session() as db:
document = await db.get(Document, document_id)
template = await db.get(Template, template_id)
if document is None or template is None:
update_progress(document_id, status="failed", message="生成任务初始化失败")
return
result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
paragraphs = result.scalars().all()
total = len(paragraphs)
request_payload = {}
if document.request_payload_json:
try:
request_payload = json.loads(document.request_payload_json)
except Exception:
request_payload = {}
file_map = request_payload.get("file_map", {}) if isinstance(request_payload, dict) else {}
update_progress(document_id, status="generating", total=total, done=0, percent=0, message="开始生成...")
done_count = 0
failed_count = 0
try:
for index, paragraph in enumerate(paragraphs, start=1):
if is_cancel_requested(document_id):
document.status = "cancelled"
document.error = "用户已取消生成"
await db.commit()
update_progress(document_id, status="cancelled", percent=min(99, int(done_count / max(total, 1) * 100)), message="已取消生成", done=done_count)
return
if paragraph.edit_mode == "manual":
content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]}
status = "success"
duration = 0
error_message = ""
model_id = paragraph.model_id
else:
start = time.perf_counter()
model = await get_effective_model(paragraph)
model_id = model.id if model is not None else paragraph.model_id
try:
selected_file_paths = file_map.get(str(paragraph.id), [])
file_summaries = []
if selected_file_paths:
file_name_mapping = {}
for paragraph_item in request_payload.get("paragraphs", []):
for selected_file in paragraph_item.get("selected_files", []):
file_path = selected_file.get("file_path")
if file_path in selected_file_paths:
file_name_mapping[file_path] = selected_file.get("file_name")
file_summaries = await asyncio.to_thread(
summarize_minio_files,
selected_file_paths,
file_name_mapping,
)
if model is None:
content = build_mock_content(paragraph)
else:
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
result_data = await call_ai(paragraph, model, file_summaries)
content = result_data.content
status = "success"
error_message = ""
except Exception as error:
content = build_mock_content(paragraph)
status = "failed"
error_message = str(error)
failed_count += 1
duration = round(time.perf_counter() - start, 4)
log = GenerationLog(
document_id=document.id,
paragraph_id=paragraph.id,
model_id=model_id,
status=status,
content=json.dumps(content, ensure_ascii=False),
duration=duration,
error_msg=error_message,
)
db.add(log)
done_count += 1
document.para_count_done = done_count
percent = int(done_count / max(total, 1) * 100)
update_progress(
document_id,
status="generating",
percent=percent,
done=done_count,
total=total,
current_paragraph=paragraph.title,
message=f"正在生成:{paragraph.title}",
)
await db.commit()
document.status = "completed" if failed_count == 0 else "failed"
document.error = "" if failed_count == 0 else f"{failed_count} 个段落生成失败,已回退为模拟结果。"
document.file_path = f"mock://document/{document.id}"
document.updated_at = datetime.now()
await db.commit()
update_progress(
document_id,
status=document.status,
percent=100,
done=done_count,
total=total,
message="生成完成" if failed_count == 0 else document.error,
)
except Exception as error:
document.status = "failed"
document.error = str(error)
await db.commit()
update_progress(document_id, status="failed", message=str(error), done=done_count, total=total)
finally:
generation_cancel_flags.pop(document_id, None)
+74
View File
@@ -0,0 +1,74 @@
import io
from datetime import timedelta
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=timedelta(seconds=expires))
def split_bucket_path(file_path: str) -> tuple[str, str]:
if "/" not in file_path:
raise ValueError("非法的 MinIO 文件路径")
return file_path.split("/", 1)
def download_object_bytes(bucket: str, object_name: str) -> bytes:
response = minio_client.get_object(bucket, object_name)
try:
return response.read()
finally:
response.close()
response.release_conn()
def upload_bytes(
bucket: str,
object_name: str,
content: bytes,
content_type: str = "application/octet-stream",
):
minio_client.put_object(
bucket,
object_name,
io.BytesIO(content),
len(content),
content_type=content_type,
)
def delete_object(bucket: str, object_name: str):
minio_client.remove_object(bucket, object_name)
+35
View File
@@ -0,0 +1,35 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from config import settings
def _build_fernet() -> Fernet:
raw_key = settings.ENCRYPTION_KEY.encode("utf-8")
digest = hashlib.sha256(raw_key).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_text(value: str) -> str:
if not value:
return ""
return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_text(value: str) -> str:
if not value:
return ""
try:
return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken:
return ""
def mask_secret(value: str) -> str:
if not value:
return ""
if len(value) <= 7:
return "*" * len(value)
return f"{value[:3]}****{value[-4:]}"
+282
View File
@@ -0,0 +1,282 @@
import json
import re
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
anchor_title: str
title: str
content: str
style_json: str
is_table: bool
table_json: str
write_mode: str
block_type: str = "text"
placeholder_key: str = ""
variable_key: str = ""
default_value: str = ""
edit_mode: str = "manual"
output_format: str = "text"
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,
}
PLACEHOLDER_PATTERN = re.compile(r"^\{\{\s*([a-zA-Z0-9_\-\.]+)\s*\}\}$")
def _build_block_title(text: str, fallback: str) -> str:
normalized = " ".join((text or "").split())
if not normalized:
return fallback
return normalized[:24] + ("..." if len(normalized) > 24 else "")
def _classify_placeholder(text: str) -> tuple[str, str, str]:
matched = PLACEHOLDER_PATTERN.match(text.strip())
if not matched:
return "text", "", ""
key = matched.group(1)
lowered = key.lower()
if any(token in lowered for token in ("summary", "opening", "section", "content", "analysis")):
return "ai_slot", key, ""
return "variable", "", key
def parse_template(file_path: str) -> list[ParsedParagraph]:
document = Document(file_path)
parsed: list[ParsedParagraph] = []
current_heading: str | None = None
current_heading_style_json = "{}"
loose_table_count = 0
body_block_count = 0
preface_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_heading = text
current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False)
body_block_count = 0
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=text,
title=text,
content="",
style_json=current_heading_style_json,
is_table=False,
table_json="{}",
write_mode="replace_heading_only",
block_type="heading",
edit_mode="manual",
output_format="text",
))
continue
block_type, placeholder_key, variable_key = _classify_placeholder(text)
if current_heading is None:
preface_count += 1
anchor_title = f"文档起始_{preface_count}"
title = _build_block_title(text, anchor_title)
write_mode = "replace_section"
else:
body_block_count += 1
anchor_title = current_heading
title = _build_block_title(text, f"{current_heading}-正文{body_block_count}")
write_mode = "append_after_heading"
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=anchor_title,
title=title,
content=text,
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
is_table=False,
table_json="{}",
write_mode=write_mode,
block_type=block_type,
placeholder_key=placeholder_key,
variable_key=variable_key,
default_value="" if variable_key else text,
edit_mode="ai" if block_type == "ai_slot" else "manual",
output_format="text",
))
else:
table_data = _extract_table_data(block)
table_text = f"[表格] {table_data['rows']}{table_data['cols']}"
if current_heading is None:
loose_table_count += 1
anchor_title = f"表格_{loose_table_count}"
title = anchor_title
write_mode = "replace_section"
else:
body_block_count += 1
anchor_title = current_heading
title = f"{current_heading}-表格{body_block_count}"
write_mode = "append_after_heading"
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=anchor_title,
title=title,
content=table_text,
style_json=current_heading_style_json if current_heading else "{}",
is_table=True,
table_json=json.dumps(table_data, ensure_ascii=False),
write_mode=write_mode,
block_type="table",
default_value=table_text,
edit_mode="manual",
output_format="table",
))
return parsed
+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:
+239
View File
@@ -0,0 +1,239 @@
# 任务执行摘要
## 会话 ID: local-20260702145410
- [2026-07-02 14:54:10]
- **执行原因**: 按任务拆解清单逐项推进,优先打通模板管理与模板解析的第一条可用链路,并同步回填已完成状态。
- **执行过程**:
1. 核对任务拆解清单、开发规范和模板格式规范,确认项目约束与当前仓库状态。
2. 检查前后端现状,识别出前端脚手架、数据库模型和多页面壳子已存在,但后端核心路由仍为空。
3. 新增模板解析服务,基于 `python-docx` 实现标题识别、正文归并、样式提取、表格基础结构提取和结构化输出。
4. 实现模板 CRUD 路由,补齐模板列表、详情、上传解析、段落配置保存和删除能力,并接入 MinIO 模板存储。
5. 为后端补充统一错误响应格式与空路由占位,避免应用启动时报错。
6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。
7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。
- **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。
## 会话 ID: local-20260702145958
- [2026-07-02 14:59:58]
- **执行原因**: 用户要求先提交当前代码,并继续完善到可以初步使用的程度。
- **执行过程**:
1. 将首批模板解析与模板管理相关改动整理后提交,提交信息使用中文。
2. 新增模型管理后端接口,支持模型列表、创建、更新、删除、状态切换与本地模拟测试。
3. 新增加密工具,按项目要求对 API Key 做 Fernet 形式加密存储并提供脱敏展示。
4. 新增生成与记录接口,支持单段测试、整份文档模拟生成、历史列表、详情预览、取消与删除。
5. 将预览页接入真实生成记录数据,将模板编辑页补齐“保存配置”和“立即测试”能力,并让历史页显示动态统计。
6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。
7. 再次执行后端语法检查与前端类型检查,确保本轮改动可用。
- **执行结果**: 当前系统已可初步走通“上传模板 → 配置段落 → 管理模型 → 触发生成 → 查看记录/预览”的联调链路,导出仍为占位提示实现。
## 会话 ID: local-20260702150745
- [2026-07-02 15:07:45]
- **执行原因**: 用户反馈项目已运行成功,要求将运行方式补充进 README,并继续完成后续任务。
- **执行过程**:
1. 重写 README,补充本地开发启动方式、Docker 启动方式、初步使用流程、常见问题与 `.idea` 误提交处理方案。
2. 扩展 MinIO 工具方法,补充对象路径拆分、字节下载、字节上传与预签名 URL 生成能力。
3. 新增 Word 导出服务,基于原始模板按标题定位段落并替换生成结果,支持基础文本与表格内容导出。
4. 更新导出路由,使“导出 Word”能够生成文件、上传到 MinIO 输出桶并返回可下载链接。
5. 再次执行后端语法检查与前端类型检查。
6. 同步更新任务拆解清单,标记当前前端页面中已实际可用的子项。
- **执行结果**: README 已补全运行说明,系统新增基础 Word 导出能力,当前可以从预览页直接导出可下载的 Word 文件。
## 会话 ID: local-20260702151621
- [2026-07-02 15:16:21]
- **执行原因**: 用户要求继续完善系统能力,并提交当前进展。
- **执行过程**:
1. 新增统一 AI 调用服务,支持 OpenAI 兼容接口与 Anthropic 接口两类模型调用。
2. 为 AI 调用补充 JSON 解析兜底、超时控制、重试机制与异常回退逻辑。
3. 将模型管理中的“连接测试”接入真实后端调用,并在前端增加测试与删除操作入口。
4. 将整份文档生成流程接入真实模型调用;当模型不可用或调用失败时,自动回退为模拟结果并记录失败状态。
5. 新增参考文件上传接口,将执行生成页的附件上传接入 MinIO,并把文件路径带入生成请求。
6. 更新 README 与任务拆解清单,标记真实模型调用与文件上传相关能力的完成状态。
7. 再次执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前系统已支持真实模型调用、模型连接测试和参考文件上传,生成链路从纯模拟升级为“真实调用优先、失败自动回退”的可用形态。
## 会话 ID: local-20260702152050
- [2026-07-02 15:20:50]
- **执行原因**: 用户反馈模板列表页缺少编辑/删除入口,且模型测试被本机 SOCKS 代理环境阻断。
- **执行过程**:
1. 重写模板管理列表页,补齐“编辑”和“删除”操作入口,并接入删除确认提示。
2. 保持模板上传后自动跳转到编辑页,同时支持从列表直接进入模板编辑页。
3. 调整 AI 调用服务的 `httpx.AsyncClient` 配置,关闭 `trust_env`,避免读取系统 SOCKS 代理环境变量。
4. 再次执行后端语法检查与前端类型检查,验证修复稳定。
- **执行结果**: `/templates` 页面现已支持直接编辑和删除模板,模型连接测试不再依赖系统 SOCKS 代理配置。
## 会话 ID: local-20260702152537
- [2026-07-02 15:25:37]
- **执行原因**: 按继续完善要求,补齐生成过程中的实时进度展示与取消能力。
- **执行过程**:
1. 新增生成运行时服务,维护任务进度状态、取消标记和后台生成逻辑。
2. 将整份文档生成改为“创建任务后后台执行”,避免接口阻塞等待。
3. 新增 SSE 进度路由,向前端持续推送生成百分比、当前段落和状态变化。
4. 将执行生成页接入 `EventSource`,显示真实进度,并补充“取消生成”按钮。
5. 更新 README 与任务拆解清单,标记 SSE、取消生成、生成结果入库等已完成项。
6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前生成流程已支持后台执行、SSE 实时进度推送和取消生成,执行页的进度展示从假进度升级为真实任务状态。
## 会话 ID: local-20260702152846
- [2026-07-02 15:28:46]
- **执行原因**: 用户反馈 DeepSeek 模型连接测试返回 400,需要修正接口兼容逻辑。
- **执行过程**:
1. 对照 DeepSeek 官方文档检查 OpenAI 兼容接口地址格式。
2. 调整 OpenAI 兼容端点拼接逻辑,对 `api.deepseek.com` 特判为 `/chat/completions`,避免误拼成 `/v1/chat/completions`
3. 补充模型调用错误信息,失败时输出更多响应正文,便于区分模型名错误、余额不足或参数不合法。
4. 执行后端语法检查,确认修复稳定。
- **执行结果**: DeepSeek OpenAI 兼容地址的拼接逻辑已修正,后续模型测试若仍失败,将返回更具体的上游响应内容便于排查。
## 会话 ID: local-20260702153354
- [2026-07-02 15:33:54]
- **执行原因**: 用户指出页面没有参考原型稿,需要开始按 [原型v3-HTML](/Users/zhouwentao/Workspaces/Yangliu/doc-forge/docs/原型v3-HTML/) 对齐界面。
- **执行过程**:
1. 重新阅读模板管理、模型管理、执行生成三个原型页面,提取顶部导航、面包屑、卡片、按钮和双栏布局结构。
2. 重写全局 `App.vue`,将侧边栏导航改为更接近原型的顶部导航结构。
3. 重写模板管理页,将表格列表改为原型风格的卡片式模板列表,并保留编辑、删除、前去生成等真实操作。
4. 重写模型管理页,将页面调整为原型风格的模型卡片列表,同时保留连接测试、启用禁用和编辑能力。
5. 重写执行生成页,使其更接近原型中的左侧模板概览 + 右侧文件配置 + 左下状态区布局,并保留真实 SSE 进度与取消生成能力。
6. 执行前端类型检查,确认本轮页面重构稳定。
- **执行结果**: 现有三大主页面已开始按原型稿收口,整体信息层级和布局结构明显向原型靠齐,同时保留了当前已完成的真实业务能力。
## 会话 ID: local-20260702161641
- [2026-07-02 16:16:41]
- **执行原因**: 用户指出模板编辑页尚未对齐原型编辑态,且“立即测试”未弹出文件选择并完成多文件测试链路。
- **执行过程**:
1. 重新核对模板管理原型中的三栏编辑态和段落测试三步弹窗交互。
2. 新增文件摘要服务,支持从 MinIO 下载并解析多种参考文件内容,包括 txt、md、csv、xlsx、xls、docx、pdf。
3. 调整 AI 调用服务,在请求模型时拼接“模板上下文 + 段落提示词 + 文件摘要”作为输入。
4. 更新段落测试接口,支持接收多文件路径、解析文件内容并把解析摘要返回前端展示。
5. 重写模板编辑页,使其更接近原型的三栏编辑结构,并实现“立即测试”三步弹窗、多文件上传、文件摘要展示和测试结果预览。
6. 增补 PDF 与老式 Excel 解析依赖,并执行后端语法检查与前端类型检查。
- **执行结果**: 模板编辑页已更接近原型编辑态,“立即测试”现支持多文件上传、文件内容解析、带提示词调用 AI 模型并返回结果。
## 会话 ID: local-20260702163801
- [2026-07-02 16:38:01]
- **执行原因**: 用户要求先提交代码后继续完善,并新增模型“是否支持流式传输”配置及模板测试流式返回能力。
- **执行过程**:
1. 先提交“模板测试弹窗与多文件解析链路”改动,保持工作区清晰。
2. 为模型表新增 `supports_streaming` 字段,并在数据库初始化时兼容旧表自动补列。
3. 更新模型创建、编辑、列表返回结构,以及模型管理页表单和展示,支持配置是否开启流式传输。
4. 扩展 AI 服务,新增 OpenAI / Anthropic 的流式输出能力。
5. 为段落测试新增流式接口;当模型开启流式能力时,模板编辑页测试弹窗在处理中阶段实时显示模型返回内容。
6. 执行后端语法检查与前端类型检查,确认本轮流式能力改动稳定。
- **执行结果**: 当前系统已支持在模型配置中开启流式传输,并在模板编辑页对开启流式的模型进行实时测试返回。
## 会话 ID: local-20260702165813
- [2026-07-02 16:58:13]
- **执行原因**: 用户希望在模型设置中增加“是否开启思考”能力,并确认是否能接入现有测试与生成流程。
- **执行过程**:
1. 为模型表、初始化 SQL、后端 Schema 和模型管理页补齐 `enable_reasoning` 字段,支持创建、编辑和展示思考模式开关。
2. 修正段落测试、流式测试、模型连接测试和整份文档生成链路,将模型上的思考模式显式传递给 AI 提示词构建逻辑。
3. 执行后端语法检查与前端类型检查,确认本轮改动稳定可用。
- **执行结果**: 当前系统已支持在模型配置中开启或关闭思考模式,且会同时作用于模型连接测试、模板编辑页测试和正式生成流程。
## 会话 ID: local-20260702170209
- [2026-07-02 17:02:09]
- **执行原因**: 用户希望在提交给 AI 的参考文件内容中显式带上文件名,便于模型理解每份内容对应的来源文件。
- **执行过程**:
1. 调整 AI 提示词中的参考文件拼接格式,将多文件上下文改为“文件:xxx”加“内容:...”的结构化文本。
2. 修正流式预览调用,确保测试流式场景也复用同一套系统提示词与文件上下文格式。
3. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前无论普通测试还是流式测试,AI 在接收参考文件时都能明确看到每个文件的文件名与对应内容摘要。
## 会话 ID: local-20260702170627
- [2026-07-02 17:06:27]
- **执行原因**: 用户希望参考文件支持一次选择多个,并将已上传文件保存为系统历史,后续可直接复用或重新上传。
- **执行过程**:
1. 新增 `reference_files` 数据表与后端模型,在参考文件上传成功后持久化保存文件名、对象路径、大小、类型和创建时间。
2. 新增历史文件列表接口,支持按文件名搜索和分页读取已上传的参考文件记录。
3. 重写模板编辑页测试弹窗的文件区,保留多文件本地上传,同时新增历史文件库选择区,允许“历史文件 + 新上传文件”混合提交给 AI。
4. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前段落测试已支持多文件一起提交,且上传过的参考文件会进入系统历史库,后续可以直接勾选历史文件或重新上传新文件。
## 会话 ID: local-20260702171007
- [2026-07-02 17:10:07]
- **执行原因**: 用户反馈上传参考文件时报“文件类型不支持”,需要补齐支持范围并提升报错可读性。
- **执行过程**:
1. 扩展后端参考文件白名单,补充 `.doc``.xlsm``.json` 等常见文件后缀。
2. 调整文件摘要服务,支持解析 `.xlsm`,并对 `.doc` 返回明确的转换建议说明。
3. 优化上传接口错误提示,返回实际不支持的扩展名和当前支持列表。
4. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。
- **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。
## 会话 ID: local-20260702173843
- [2026-07-02 17:38:43]
- **执行原因**: 用户要求先提交当前代码,并继续修复参考文件原名称丢失问题,同时核实 `.doc`、Excel、PDF 的内容提取能力。
- **执行过程**:
1. 提交当前阶段代码,提交信息为“完善模型测试与参考文件历史能力”,保持工作区清晰。
2. 修正参考文件摘要逻辑,优先从 `reference_files` 历史记录中读取原始文件名,不再把 MinIO 中的 UUID 文件名传给 AI。
3.`.doc` 文件接入 macOS `textutil` 文本提取能力,补齐旧版 Word 文档正文解析。
4. 修正 Excel 提取分支,确保 `.xlsm``.xlsx` 走正确读取方式;保留现有 PDF 文本提取逻辑。
5. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前参考文件在提交给 AI 时会保留用户原始文件名;`.doc``.docx`、Excel、PDF 等常见文件均可进入提取链路,其中 Excel 和 PDF 原本已支持,本轮补强了 `.doc``.xlsm`
## 会话 ID: local-20260702175543
- [2026-07-02 17:55:43]
- **执行原因**: 用户提出系统缺少单独的附件历史菜单,希望可以直接查看已经上传过的文件。
- **执行过程**:
1. 新增前端附件历史页面,展示参考文件总数、当前页数量、文件名、对象路径、类型、大小和上传时间。
2. 将页面接入现有 `reference-files` 接口,支持按文件名搜索和分页查看。
3. 在顶部导航中新增“附件历史”菜单入口,并补充路由配置。
4. 执行前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前系统已新增独立的“附件历史”菜单,用户可直接查看和检索已上传的参考文件记录。
## 会话 ID: local-20260702181456
- [2026-07-02 18:14:56]
- **执行原因**: 用户希望附件历史支持下载、改名、查看与删除,同时要求生成页面支持多文件和文件说明,并让生成任务脱离当前页面、在记录页可持续查看状态与附件映射。
- **执行过程**:
1. 扩展参考附件接口,新增附件下载、改名、删除能力,并在附件历史页补齐对应操作按钮。
2. 为生成任务数据模型新增请求快照字段,在提交任务时持久化保存模板名、段落文件说明以及每个段落选中的附件列表。
3. 调整正式生成运行时,让每个段落在后台生成时真正读取该段落绑定的多文件内容,并把原始文件名传给 AI。
4. 重写执行生成页,展示每个段落的文件说明,支持多文件上传与移除,并将页面改为“提交任务后立即转到任务详情”模式,不再绑死在提交页。
5. 重写任务详情页,支持查看任务状态、实时进度、段落与附件映射、已完成段落内容,以及在运行中取消任务。
6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前附件历史已支持基础管理;生成任务提交后会在后台继续执行,用户可离开提交页并在任务详情中持续查看状态、段落附件映射与生成结果。
## 会话 ID: local-20260702182202
- [2026-07-02 18:22:02]
- **执行原因**: 用户反馈 MySQL 启动时报 `TEXT` 列不能设置默认值,需要修复 `request_payload_json` 字段的数据库兼容性。
- **执行过程**:
1. 调整 `documents.request_payload_json` 的模型定义,取消 `TEXT` 列默认值,改为允许空值并由代码层负责兜底。
2. 修正数据库初始化与自动补列逻辑,避免执行 `ALTER TABLE ... TEXT DEFAULT '{}'` 这一类 MySQL 非法 SQL。
3. 同步更新 `init.sql``documents` 表定义,去掉 `request_payload_json` 的默认值。
4. 执行后端语法检查,确认修复稳定。
- **执行结果**: 当前 `request_payload_json` 字段已兼容 MySQL,应用启动时不会再因为 `TEXT DEFAULT` 语句失败。
## 会话 ID: local-20260702182417
- [2026-07-02 18:24:17]
- **执行原因**: 用户反馈提交生成任务时报 `Object of type datetime is not JSON serializable`,需要修复任务快照里的附件时间字段序列化。
- **执行过程**:
1. 检查生成任务快照构建逻辑,确认报错来源于附件记录中的 `created_at``datetime` 对象。
2. 调整参考附件序列化方法,将 `created_at` 统一转为 ISO 字符串后再写入任务快照 JSON。
3. 执行后端语法检查,确认修复稳定。
- **执行结果**: 当前生成任务提交时不会再因为附件记录中的 `datetime` 字段导致 JSON 序列化失败。
## 会话 ID: local-20260702182751
- [2026-07-02 18:27:51]
- **执行原因**: 用户反馈导出的模板与原模板几乎一致,AI 结果没有正确写入,同时模板样式在导出后发生明显偏移。
- **执行过程**:
1. 检查导出链路,确认原实现采用“删除原内容后新建普通段落/表格”的方式回写,容易导致标题命中不稳和样式丢失。
2. 重构导出服务的段落替换逻辑,改为按标题顺序匹配模板中的章节,减少重复标题导致的误替换风险。
3. 调整导出时的文本回写方式,优先复用模板原有段落的段落属性与首个 run 的字体样式,再写入 AI 返回文本。
4. 调整导出时的表格回写方式,优先克隆模板原有表格结构并填充新数据,尽量保留表格外观和基础样式。
5. 执行后端语法检查,确认导出服务改动稳定。
- **执行结果**: 当前导出链路已改为“尽量复用模板原始段落/表格样式后写入 AI 内容”,比此前的新建空白内容块方式更接近原模板样式,也更容易把 AI 结果正确写回文档。
## 会话 ID: local-20260702202424
- [2026-07-02 20:24:24]
- **执行原因**: 用户希望用极简一句话概括当前项目功能状态,并说明明天的工作重点。
- **执行过程**:
1. 基于当前已完成能力,压缩总结项目现状。
2. 提炼明日优先事项,聚焦导出准确性与样式保真。
- **执行结果**: 已形成简短状态说明,可作为明日继续开发的工作摘要。
## 会话 ID: local-20260702202807
- [2026-07-02 20:28:07]
- **执行原因**: 用户希望将任务详情页改为“上 + 左右”结构,左侧显示段落,右侧显示生成结果预览。
- **执行过程**:
1. 重构任务详情页布局,将页面调整为顶部任务概览、下方左右分栏的结构。
2. 左侧增加段落列表与段落状态展示,并保留附件映射信息。
3. 右侧改为当前选中段落的结果预览区域,展示段落标题、状态、文件要求和生成内容。
4. 执行前端类型检查,确认本轮布局调整稳定。
- **执行结果**: 当前任务详情页已改为“上 + 左右”结构,用户可在左侧切换段落,在右侧查看对应生成结果预览。
+164
View File
@@ -0,0 +1,164 @@
# 任务执行摘要
## 会话 ID: local-20260703092828
- [2026-07-03 09:28:28]
- **执行原因**: 用户要求提交当前已完成的预览页布局调整代码。
- **执行过程**:
1. 检查当前工作区改动,确认仅包含任务详情页左右布局调整和对应任务记录。
2. 整理并暂存相关文件,排除未跟踪的原型目录。
3. 准备使用中文提交信息完成本次代码提交。
- **执行结果**: 当前改动已整理完成并准备提交,包含任务详情页“上 + 左右”结构调整。
## 会话 ID: local-20260703092933
- [2026-07-03 09:29:33]
- **执行原因**: 用户希望预览页左右两栏固定在顶层显示,不随页面整体滚动,而是在局部区域内滚动。
- **执行过程**:
1. 调整任务详情页根容器高度和溢出策略,禁止页面整体滚动。
2. 为左右分栏区域设置固定可用高度和内部滚动容器,使左侧段落列表、右侧预览内容各自滚动。
3. 执行前端类型检查,确认布局调整稳定。
- **执行结果**: 当前预览页已改为页面整体固定、左右两栏局部滚动的显示方式,顶部信息区域保持固定可见。
## 会话 ID: local-20260703093617
- [2026-07-03 09:36:17]
- **执行原因**: 用户询问当前模板导入时如何识别 `.doc/.docx` 文件中的段落边界。
- **执行过程**:
1. 定位模板上传入口与解析服务,确认模板导入的实际文件格式限制。
2. 阅读 `template_parser` 实现,核对标题识别、正文归并和表格归属逻辑。
3. 结合设计文档整理当前段落识别规则与边界行为,准备向用户说明。
- **执行结果**: 已确认当前模板导入仅支持 `.docx`;段落边界基于 Word 内置 `Heading` 样式识别,普通正文归并到最近标题下,表格归属最近段落或独立成段。
## 会话 ID: local-20260703093913
- [2026-07-03 09:39:13]
- **执行原因**: 用户进一步询问特殊模板场景下,是否支持只修改标题、不修改标题下固定内容,以及是否可以手动调整段落与模板内容。
- **执行过程**:
1. 核对模板编辑页界面与保存逻辑,确认当前可编辑字段范围。
2. 检查后端模板保存 schema,确认是否支持手动拆段、合段或正文内容持久化编辑。
3. 基于现状整理可行产品方案,包括自动识别候选标题与人工微调两类路径。
- **执行结果**: 已确认当前系统支持修改段落标题和生成配置,但暂不支持手动拆段/合段,也不支持在系统内直接编辑模板正文;可通过新增“标题仅替换”模式与手动段落调整能力满足该场景。
## 会话 ID: local-20260703094127
- [2026-07-03 09:41:27]
- **执行原因**: 用户希望模板编辑阶段支持人工直接编辑模板内容,并讨论是否应改为在标题下方占位填充而非整段替换,同时询问在线文档编辑实现思路。
- **执行过程**:
1. 检查当前导出实现,确认模板内容替换的实际粒度与边界。
2. 结合现有解析和导出方式,评估“段落模式”与“手动编辑 Word 模式”的双模式方案。
3. 查阅腾讯文档相关公开资料,整理在线文档通常采用的协同编辑架构和导入导出模型。
- **执行结果**: 已确认当前导出为标题间整段替换;建议新增“手动编辑模板内容”与“占位填充”能力,并采用结构化文档模型而非直接把 `.docx` 当在线编辑源格式处理。
## 会话 ID: local-20260703094614
- [2026-07-03 09:46:14]
- **执行原因**: 用户确认实施模板编辑增强,要求支持“段落配置 / 手动编辑模板”切换,并改进 AI 内容写回 Word 的方式。
- **执行过程**:
1. 扩展段落数据结构,新增原始标题锚点和写入方式字段,并补充数据库自动迁移逻辑。
2. 改造模板编辑页,增加“段落配置 / 手动编辑模板”切换,支持直接编辑标题、正文和写入方式。
3. 改造导出逻辑,支持仅替换标题、标题下插入内容、替换整段三种写入模式,同时保持原始标题定位能力。
4. 执行后端编译检查与前端 `npm run build`,确认本轮改动可正常通过。
- **执行结果**: 模板编辑页现已支持结构化手动编辑;导出时可按段落配置选择“整段替换 / 标题下插入 / 仅改标题”,能更好处理固定正文与 AI 生成内容并存的模板场景。
## 会话 ID: local-20260703094757
- [2026-07-03 09:47:57]
- **执行原因**: 用户需要本轮模板编辑增强对应的数据库增量 SQL。
- **执行过程**:
1. 对照本轮后端模型与初始化脚本,确认实际新增的持久化字段。
2. 整理兼容现有数据的 `ALTER TABLE` 与回填语句,确保旧模板可正常迁移。
3. 记录增量说明,便于后续环境执行与核验。
- **执行结果**: 已输出可直接执行的 MySQL 增量 SQL,包含 `paragraphs.anchor_title``paragraphs.write_mode` 两个新字段及历史数据回填语句。
## 会话 ID: local-20260703095307
- [2026-07-03 09:53:07]
- **执行原因**: 用户质疑当前模板编辑仍像段落配置而非在线 Word 编辑,并询问是否可以手动插入新的 AI 段落。
- **执行过程**:
1. 重新核对模板解析逻辑,确认当前仍以 Word `Heading` 样式作为段落边界。
2. 核对导出写回逻辑,确认当前是围绕已识别标题区块进行替换或插入,而不是对文档块级结构进行自由编辑。
3. 基于用户反馈梳理下一阶段应改造为“块级在线文档编辑 + AI 占位段落”的方向。
- **执行结果**: 已明确当前系统还不支持像腾讯文档那样手动插入新段落块;若要满足该诉求,应将模板编辑从“段落配置”升级为“文档块编辑”,支持新增 AI 段落占位、拆分正文块与固定块。
## 会话 ID: local-20260703095628
- [2026-07-03 09:56:28]
- **执行原因**: 用户要求继续推进,支持在模板中手动拆块并插入 AI 段落。
- **执行过程**:
1. 扩展模板保存接口,支持创建新块、删除旧块,并按当前编辑顺序重排 `sort_index`
2. 改造导出逻辑,按连续的 `anchor_title` 分组写回同一节内容,使一个原标题下可挂多个手动/AI 块。
3. 改造模板编辑页,在手动编辑模式下新增“在后面新增固定块 / AI 块 / 删除当前块”操作。
4. 执行后端编译检查与前端 `npm run build`,确认新增块编辑能力可正常通过构建。
- **执行结果**: 当前模板编辑已支持把同一原标题下的内容手动拆成多个块,并插入新的 AI 块或固定块;导出时会按块顺序写回同一节内容,较之前更接近在线文档式的人工干预流程。
## 会话 ID: local-20260703095940
- [2026-07-03 09:59:40]
- **执行原因**: 用户要求模板默认导入后全部识别为人工手动,而不是 AI 生成。
- **执行过程**:
1. 调整段落模型默认值与初始化脚本默认值,将 `edit_mode` 默认改为 `manual`
2. 调整模板上传落库逻辑,显式将新导入段落设置为 `manual`,避免受历史数据库默认值影响。
3. 执行后端编译检查,确认默认值调整未引入语法或依赖问题。
- **执行结果**: 新导入模板中的识别段落现在默认全部为人工手动;如需 AI 生成,需要用户在模板编辑页中显式切换对应块为 AI 模式。
## 会话 ID: local-20260703100253
- [2026-07-03 10:02:53]
- **执行原因**: 用户反馈模板编辑页 `doc-edit-page` 没有随内容高度增长,导致内容超出纸张容器显示。
- **执行过程**:
1. 检查编辑页中部滚动区与纸张容器的 flex 布局关系,定位到默认纵向拉伸导致纸张高度被固定。
2. 调整 `center-scroll` 的对齐方式为顶部对齐,并禁止 `doc-edit-page` 在 flex 布局中被压缩。
3. 执行前端 `npm run build`,确认样式修复后页面仍可正常构建。
- **执行结果**: 模板编辑页中的纸张容器现在会按内容自然增高,不再因为父级 flex 拉伸而出现内容超出容器显示的问题。
## 会话 ID: local-20260703100656
- [2026-07-03 10:06:56]
- **执行原因**: 用户询问执行生成页是否支持从历史文件中复用已上传附件。
- **执行过程**:
1. 检查 `GeneratePage.vue` 的文件上传区域与状态管理逻辑,确认当前前端入口能力。
2. 对照 `generateApi` 与后端 `reference-files` 接口,确认后端已有历史文件查询能力是否被生成页接入。
3. 整理当前支持范围与缺口,准备向用户说明现状与后续改造方向。
- **执行结果**: 已确认生成页当前仅支持新上传文件,不支持在页面内选择历史文件复用;后端已有历史文件接口,但该页尚未接入对应 UI 与选择逻辑。
## 会话 ID: local-20260703104125
- [2026-07-03 10:41:25]
- **执行原因**: 用户建议参考模板编辑中的历史文件复用能力,并封装成通用组件供执行生成页复用。
- **执行过程**:
1. 抽离公共 `ReferenceFileSelector` 组件,统一封装新上传、历史文件搜索复用、已选文件展示与移除逻辑。
2. 将模板编辑页段落测试弹窗接入该组件,替换原有分散的上传与历史文件逻辑。
3. 将执行生成页接入同一组件,使每个需上传文件的段落同时支持上传新文件和选择历史文件。
4. 执行前端 `npm run build`,确认组件复用后页面构建正常。
- **执行结果**: 当前模板编辑测试弹窗与执行生成页已共用同一套文件选择组件;执行生成页现已支持历史文件复用,不再局限于本次新上传。
## 会话 ID: local-20260703104741
- [2026-07-03 10:47:41]
- **执行原因**: 用户希望模型管理页支持厂商预设、DeepSeek 余额查看,以及将测试按钮改为带转圈的刷新式提示。
- **执行过程**:
1. 扩展模型前端 API 与 store,新增余额查询调用。
2. 在后端模型路由中新增 DeepSeek 余额查询接口,并基于已保存的 API Key 调用官方余额接口。
3. 改造模型管理页,新增 `DeepSeek / 自定义` 厂商预设、DeepSeek 余额展示与查询按钮。
4. 将测试按钮改为带 loading 的“刷新测试”,结果改为自动消失的轻提示,不再使用需要手动关闭的弹窗。
5. 执行后端编译检查与前端 `npm run build`,确认改动可正常构建。
- **执行结果**: 模型管理页现已支持厂商预设;DeepSeek 模型可直接查询余额;测试按钮改为更轻量的刷新式交互,点击后会转圈并自动提示结果。
## 会话 ID: local-20260703104944
- [2026-07-03 10:49:44]
- **执行原因**: 用户发现将厂商改成自定义后,页面仍被识别为 DeepSeek,且自定义厂商也出现余额查询能力。
- **执行过程**:
1. 排查模型管理页与后端余额接口的 DeepSeek 判定条件。
2. 将判定逻辑从“厂商或 endpoint 命中 DeepSeek”收紧为“仅当 provider 明确为 DeepSeek 时才视为 DeepSeek 模型”。
3. 执行后端编译检查与前端 `npm run build`,确认修正后功能正常。
- **执行结果**: 当前只有在厂商明确设置为 `DeepSeek` 时,页面才会显示 DeepSeek 预设状态与余额查询按钮;改为自定义厂商后不会再被 endpoint 误判为 DeepSeek。
## 会话 ID: local-20260703111410
- [2026-07-03 11:14:10]
- **执行原因**: 用户要求将当前阶段改动提交到 Git。
- **执行过程**:
1. 检查工作区变更,确认本轮后端、前端与任务记录文件可一并提交。
2. 排除未跟踪的原型目录,仅暂存本次功能实现相关文件。
3. 使用中文提交信息完成本次代码提交。
- **执行结果**: 当前模板编辑、历史文件复用、模型管理增强等改动已整理完成,准备提交到本地 Git 历史。
## 会话 ID: local-20260703154400
- [2026-07-03 15:44:00]
- **执行原因**: 用户需要在模板编辑器中支持段落删除与移动排序功能,同时修复删除后导出 Word 仍有残留段落的问题。
- **执行过程**:
1. 改造前端 TemplateEditor.vue,在左侧段落列表、段落配置预览区、手动编辑区三处新增上移/下移/删除按钮,hover 时显示。
2. 新增 canMoveUp/canMoveDown/moveUp/moveDown/handleDeleteParagraph 函数,移动用 splice 交换后 normalizeSortIndex,删除走 Modal.confirm 确认框。
3. 修正 canDeleteBlock 判定逻辑(原为同 anchor_title 下有 >1 个段落才能删,改为总段落数 >1 即可删)。
4. 新增 autoSaveParagraphs 函数,移动/删除后直接调 API 自动保存,同步 store 状态。
5. 修复后端 PUT /{template_id}/paragraphs 接口:删除段落前先级联删除 generation_logs,避免 FK 约束报错。
6. 修复后端导出逻辑 document_export.py:新增 _delete_heading_section 和 _remove_unreferenced_headings 函数,导出时清理未被 generation_logs 引用的标题段落,确保已删段落的原标题和内容不会残留在 Word 中。
7. 修复后端 DELETE /{template_id} 接口:级联清理 generation_logs、documents、paragraphs,解决删除整个模板时的 FK 约束失败。
8. 添加前后端调试日志辅助排查,确认功能正常后提交代码。
- **执行结果**: 段落删除与移动排序功能完整实现,已删段落在生成导出后不再残留,模板删除 FK 约束已修复。提交 commit 1369d87。
+152
View File
@@ -0,0 +1,152 @@
# 任务执行摘要
## 会话 ID: local-20260705193723
- [2026-07-05 19:37:23]
- **执行原因**: 用户询问“执行生成”功能当前是如何实现文档导出的,希望梳理从提交生成到导出 Word 的实际代码链路。
- **执行过程**:
1. 检查执行生成页 `GeneratePage.vue`,确认前端提交任务的入口与跳转路径。
2. 检查后端 `generate.py``generation_runtime.py`,确认生成任务创建、后台执行和段落结果落库方式。
3. 检查预览页 `PreviewEdit.vue`、导出 API `export.py``document_export.py`,确认导出 Word 的触发点、模板读取方式和内容写回逻辑。
- **执行结果**: 已确认“执行生成”页本身只负责创建后台生成任务;真正的 DOCX 导出发生在任务详情页点击“导出 Word”后,由后端从 MinIO 拉取原始模板、读取 `generation_logs`、按标题锚点写回内容,再上传导出文件并返回预签名下载链接。
## 会话 ID: local-20260705194530
- [2026-07-05 19:45:30]
- **执行原因**: 用户要求先输出“在线编辑 + AI 选区 + 导出重构”的整体方案、任务清单,并评估是否可行实现。
- **执行过程**:
1. 参考现有 `03-任务拆解清单.md``04-后续迭代任务拆解清单.md` 的拆解风格,整理适合当前项目的阶段方案。
2. 结合现有模板解析、模板编辑与导出实现,识别当前架构与目标方案之间的差距。
3. 输出分阶段实施建议,并评估技术可行性、实现难点与优先级。
- **执行结果**: 已形成可执行的重构方案:以“块级在线编辑 + AI 选区标记 + 模板源同步写回 + 精确导出”为主线,建议分为编辑器重构、块模型升级、写回引擎重构、生成链路适配与联调验收五个阶段推进;整体可行,但不建议一步直追腾讯文档式完整协同编辑。
## 会话 ID: local-20260705195210
- [2026-07-05 19:52:10]
- **执行原因**: 用户要求将该方案整理成与 `03-任务拆解清单.md` 同风格的正式任务清单文档。
- **执行过程**:
1. 对照 `03-任务拆解清单.md` 的结构,统一“阶段 -> 子模块 -> 勾选项 -> 交付物”的表达方式。
2. 将“在线编辑模式、AI 选区模式、模板源同步写回、导出引擎重构”等内容拆成可执行任务项。
3. 新增正式文档到 `docs/需求与设计/`,便于后续按清单逐步实施。
- **执行结果**: 已新增 `05-模板在线编辑重构任务拆解清单.md`,内容结构与 `03-任务拆解清单.md` 保持一致,可直接作为后续实施清单使用。
## 会话 ID: local-20260705200540
- [2026-07-05 20:05:40]
- **执行原因**: 用户要求开始按 `05-模板在线编辑重构任务拆解清单.md` 落实代码。
- **执行过程**:
1. 先从第一阶段“数据模型升级”入手,新增 `template_blocks` 数据模型、初始化 DDL 和启动时自动建表逻辑。
2. 改造模板接口返回结构,在保留旧 `paragraphs` 兼容的同时,新增 `blocks` 序列化输出。
3. 改造模板上传与保存逻辑,使新上传模板会自动同步生成块数据;保存段落时若前端尚未显式传块,则自动从段落重建块,确保兼容过渡。
4. 更新前端 `types/store`,接入 `blocks` 字段;补充本轮增量 SQL 文档,并将任务清单中已完成的数据模型项勾选。
5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit` 校验,确认本轮改动通过编译检查。
- **执行结果**: 已完成“模板在线编辑重构”第一阶段中的数据模型升级基础设施,系统现已具备 `template_blocks` 持久化能力,并能在不破坏现有模板编辑流程的前提下为后续块级编辑器改造提供后端承载。
## 会话 ID: local-20260705202018
- [2026-07-05 20:20:18]
- **执行原因**: 用户要求继续推进模板在线编辑重构,优先落实第二阶段的模板块级解析能力。
- **执行过程**:
1. 改造 `template_parser.py`,将导入解析从“按 Heading 聚合大段”调整为“标题块 + 正文块 + 表格块”的细粒度块流。
2. 为解析结果补充块元信息,包括 `block_type``placeholder_key``variable_key``edit_mode``output_format`,并对显式 `{{ xxx }}` 占位做初步分类。
3. 改造模板上传逻辑,创建 `Paragraph` 时同步写入更细粒度的导入结果,并基于解析结果直接生成 `template_blocks`
4. 更新任务清单勾选状态,标记已完成的“标题块/正文块/表格块/块级 JSON 结构”等子项。
5. 再次执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认本轮解析器重构未引入编译错误。
- **执行结果**: 当前模板导入阶段已具备初步块级解析能力,导入后的结构不再只是一整段聚合文本,而是更接近后续在线编辑所需的块流模型,为第一页内容拆分和 AI 选区改造打下了基础。
## 会话 ID: local-20260705203240
- [2026-07-05 20:32:40]
- **执行原因**: 用户继续推进,要求将模板编辑页的“手动编辑模板”模式真正切换到块模型上。
- **执行过程**:
1. 改造 `TemplateEditor.vue` 的选择与渲染逻辑,引入 `blocks` 本地状态以及 `currentItems / selectedConfigItem` 计算属性。
2. 保留原“段落配置”模式兼容现有流程,同时让“手动编辑模板”模式改为基于 `blocks` 渲染左侧列表、中间编辑画布和右侧配置区。
3. 改造移动、删除、插入、自动保存与保存模板逻辑,使其在手动模式下可针对块结构生效,并将 `blocks` 一并提交到模板保存接口。
4. 为块编辑模式补充块类型标签、变量键/AI 占位键配置,以及块级测试时对 `source_paragraph_id` 的兼容校验。
5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认本轮页面改造与保存链路通过编译检查。
- **执行结果**: 模板编辑页当前已实现“段落模式 / 块模式”双轨运行;其中手动编辑模板模式已开始基于 `template_blocks` 工作,块列表、块画布与右侧配置面板能够联动,为下一步实现 AI 选区模式奠定了前端基础。
## 会话 ID: local-20260705204055
- [2026-07-05 20:40:55]
- **执行原因**: 用户反馈模板编辑页保存时报 `PUT /templates/{id}/paragraphs 500`,并且进入模板编辑时页面空白。
- **执行过程**:
1. 根据报错 SQL 定位到 `template_blocks.content_json``TEXT` 字段,但上传/同步块时误将 Python `dict` 直接写入数据库。
2. 修正模板块构建逻辑,在 `_build_block_from_paragraph``_build_block_from_parsed_item` 中统一将 `content_json` 序列化为 JSON 字符串。
3. 为旧模板补充兼容逻辑:读取模板详情时若尚无 `blocks` 数据,则自动根据现有 `paragraphs` 重建块数据并写回数据库。
4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复后无编译错误。
- **执行结果**: 已修复模板保存时的 500 报错来源;老模板在没有 `blocks` 数据时也会自动补齐,模板编辑页不应再因块数据缺失而显示空白。
## 会话 ID: local-20260705204820
- [2026-07-05 20:48:20]
- **执行原因**: 用户指出当前改造“页面上完全没区别”,要求明确已完成内容与可见效果之间的差距。
- **执行过程**:
1. 复盘本轮已落地内容,区分数据层/接口层改造与实际可见的界面改造。
2. 明确当前仍未完成的“可见功能”项,包括 AI 选区、块级专属工具条、变量块突出展示、块级样式差异等。
3. 准备将后续工作重心从底层铺设切换到用户可见的编辑体验改造。
- **执行结果**: 已确认当前阶段主要完成了块模型、解析器与保存链路等基础设施,前端交互层仍缺少足够显著的视觉与操作变化;后续需优先补齐用户可感知的块级编辑与 AI 选区功能。
## 会话 ID: local-20260705205630
- [2026-07-05 20:56:30]
- **执行原因**: 用户要求继续实施,并优先看到模板编辑页中“手动编辑模板”模式的可见变化。
- **执行过程**:
1. 改造 `TemplateEditor.vue` 顶部工具条,在手动模式下新增“新增标题块 / 正文块 / AI 块 / 变量块”操作入口。
2. 改造中间块画布的卡片样式,为标题块、正文块、AI 块、变量块、表格块提供不同的边框、背景和标识信息。
3. 调整左侧列表标签与右侧配置项,使块类型、AI 占位键、变量键、固定块信息在界面上可直接感知。
4. 补齐块模式下新增、删除、上移、下移的界面交互,并修正新增块时误写入旧 `paragraphs` 数组的问题。
5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认可见层改造通过编译检查。
- **执行结果**: 模板编辑页的手动模式现在已有明显的块级编辑视觉效果与块工具条,页面不再只是“底层换数据源但外观几乎不变”;用户可直接看到并操作标题块、正文块、AI 块和变量块。
## 会话 ID: local-20260705210520
- [2026-07-05 21:05:20]
- **执行原因**: 用户追问当前是否真正完成“在线编辑文档效果”、执行生成为何未按模板段落顺序导出,以及模板编辑是否已直接影响源 `docx` 文件。
- **执行过程**:
1. 复核任务清单与当前代码链路,区分“块级编辑器界面改造”与“模板源写回 / 生成导出主链路改造”两个层面。
2. 核对 `generation_runtime.py``export.py`,确认当前执行生成和导出仍然基于旧 `paragraphs + generation_logs + template.file_path` 工作。
3. 核对 `templates.py`,确认当前模板编辑保存主要写入 `paragraphs``template_blocks`,尚未把编辑后的块结构回写到模板源 `docx`
- **执行结果**: 已明确当前“在线编辑文档效果”只完成了块级编辑器的可见前端基础,未完成模板源 `docx` 写回;执行生成和导出仍走旧段落链路,因此不会完全按新块顺序导出。这也是用户感知为“编辑后没有真正影响模板导出”的根本原因。
## 会话 ID: local-20260705211340
- [2026-07-05 21:13:40]
- **执行原因**: 用户要求继续实施,优先打通“保存模板影响源 docx”和“执行生成/导出顺序跟块走”的主链路。
- **执行过程**:
1.`templates.py` 中新增 `blocks -> paragraphs` 同步逻辑,使块顺序、块内容、AI/人工属性会反向更新旧 `Paragraph` 数据。
2. 在同一文件中新增模板快照写回逻辑:读取当前模板源 `docx`,按当前块顺序组装导出日志,通过 `export_document_bytes` 生成新的模板内容并覆盖回模板源文件。
3. 保留现有 `generation_runtime.py``export.py` 的旧段落链路不变,但通过同步段落顺序与内容,让执行生成和导出开始间接受到块顺序影响。
4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。
- **执行结果**: 当前保存模板后,后端已开始将 `template_blocks` 反向同步回 `paragraphs`,并尝试把当前块快照回写到模板源 `docx`;这为后续完全切换到 blocks 导出奠定了主链路基础,也开始让保存后的顺序和内容影响执行生成与导出。
## 会话 ID: local-20260705212155
- [2026-07-05 21:21:55]
- **执行原因**: 用户反馈“调整段落顺序后,点击保存会恢复之前的段落”。
- **执行过程**:
1. 复核前后端保存链路,确认问题出在“段落模式调整了 `paragraphs`,但保存时仍把旧 `blocks` 一并提交,后端又按旧 `blocks` 覆盖回段落顺序”。
2. 为模板保存请求新增 `save_mode` 字段,明确区分 `paragraph``manual` 两种保存语义。
3. 调整后端保存逻辑:只有在 `manual` 模式下才按 `blocks` 覆盖段落;在 `paragraph` 模式下则以 `paragraphs` 为准重建块数据,避免旧块顺序反向覆盖。
4. 调整前端 `TemplateEditor.vue``template` store,在自动保存与保存模板时按当前编辑模式传递 `save_mode`,并在段落模式下不再提交旧块数组。
5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复通过编译检查。
- **执行结果**: 已修复段落模式下“调整顺序后保存又恢复原顺序”的直接覆盖问题;当前段落模式保存会以 `paragraphs` 为准,不再被旧 `blocks` 顺序反向改写。
## 会话 ID: local-20260705213040
- [2026-07-05 21:30:40]
- **执行原因**: 用户反馈“执行生成里还是旧顺序,并且每一段都会重复导出”。
- **执行过程**:
1. 通过容器内 MySQL 查询模板 5 的 `paragraphs``template_blocks` 与最近生成记录,确认数据库中的最新顺序实际上已同步为用户调整后的顺序。
2. 定位重复导出的根因:当前模板下每个块都是单独段落,但历史保存逻辑将所有非标题段统一标为 `append_after_heading`,导出时会保留旧正文再追加一次新正文,导致每段重复。
3.`templates.py` 中新增块级写入方式解析逻辑:同一锚点下首个内容块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`;标题块仍使用 `replace_heading_only`
4. 立刻用当前模板详情数据回调一次 `PUT /templates/5/paragraphs`,触发模板 5 重新保存,使新的写入方式同步落库并回写模板快照。
5. 再次查询数据库确认模板 5 的 `paragraphs.write_mode` 已全部从错误的 `append_after_heading` 切换为 `replace_section`
- **执行结果**: 已修复模板 5 当前“每一段重复导出”的直接根因;数据库和最新生成链路所使用的模板顺序现已与用户调整后的顺序一致。后续需要重新发起新的生成任务,旧的历史生成记录不会自动变成新顺序与新导出结果。
## 会话 ID: local-20260705213910
- [2026-07-05 21:39:10]
- **执行原因**: 用户要求继续完善,进一步降低导出链路对旧 `paragraphs` 顺序的依赖,减少排序与重复类 bug。
- **执行过程**:
1. 改造 `export.py`,让 DOCX 导出优先按 `template_blocks` 顺序组织导出日志,而不是完全依赖 `GenerationLog + Paragraph` 的旧顺序。
2. 在导出组装阶段加入块级写入方式解析逻辑:同锚点首块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`,标题块使用 `replace_heading_only`
3.`document_export.py` 中新增章节重排逻辑,根据导出日志中的锚点顺序,先调整 Word 文档中各个 Heading section 的物理顺序,再执行正文替换与插入。
4. 保留无块数据时的旧段落导出回退逻辑,避免历史模板直接失效。
5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。
- **执行结果**: 当前 DOCX 导出已开始优先按 `template_blocks` 顺序工作,并且在写回前会尝试重排 Word 标题区块顺序;这比之前仅替换原位置内容更接近“模板编辑后导出顺序真实变化”的目标。
## 会话 ID: local-20260705214830
- [2026-07-05 21:48:30]
- **执行原因**: 用户要求将当前这批模板在线编辑与导出链路改造提交到 `test_v1` 分支。
- **执行过程**:
1. 检查当前工作区改动与现有分支,确认本轮需提交的是模板块模型、编辑器、导出链路、任务清单和任务记录相关文件。
2. 新建并切换到 `test_v1` 分支,排除不属于本轮的 `docs/tasks/task_detail_2026_07_03.md`、原型目录和额外文档改动。
3. 仅暂存本轮功能相关文件,并使用中文提交信息完成提交。
- **执行结果**: 已在 `test_v1` 分支完成本轮提交,提交号为 `c84ec6a`,提交信息为“模板在线编辑与导出链路重构”。
+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 周)
- [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 天)
- [x] python-docx 打开模板,逐段落遍历
- [x] 标题样式识别(Heading 1~6)→ 段落边界
- [x] 正文内容捕获 → 合并到上一标题
- [ ] 表格结构提取(行列数、合并单元格、边框、底纹)
- [x] 样式捕获(字体名、字号、加粗、颜色、对齐、缩进、间距、行距)
- [x] 段落索引记录(在文档中的位置,用于导出定位)
- [x] 输出结构化 JSON
### AI 服务层(5-7 天)
- [x] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
- [x] Anthropic 格式适配(Claude 3.5 Sonnet
- [x] 统一接口:call_ai(paragraph, files, callback) → content
- [ ] 提示词拼接:系统提示词 + 段落预设提示词 + 文件摘要
- [x] 超时/重试/错误处理
- [ ] 并发控制(asyncio.Semaphore
- [ ] 文件摘要生成(Excel 解析 + 数据统计)
### 文档生成器(3 天)
- [x] 单段落生成流程
- [ ] 多段落并行生成编排(asyncio.gather
- [x] SSE 进度推送
- [x] 取消生成支持
- [x] 生成结果入库
### Word 导出引擎(5-7 天)
- [ ] 复制原模板文件作为骨架
- [ ] 段落定位(按索引/xpath/标题文本三级匹配)
- [ ] 纯文本替换(保留原样式)
- [ ] 表格填充(删除占位表格 → 创建新表格 → 填充数据 → 应用样式)
- [ ] 混合内容处理(文字 + 表格交替)
- [ ] PDF 导出(调用 LibreOffice 命令)
### 路由与 API3 天)
- [x] 模板 CRUD 路由
- [x] 模型 CRUD 路由
- [x] 生成相关路由(测试/全量/进度SSE/取消)
- [ ] 导出路由(Word/PDF
- [x] 文件上传/管理
## 第三阶段:前端核心开发(第 2-4 周)
### 通用组件(3 天)
- [ ] FileUploader 组件(拖拽 + 点击上传)
- [ ] ModelModal 组件(添加/编辑模型弹窗)
- [ ] TestModal 组件(段落测试三步弹窗)
- [ ] 状态标签、加载状态、空状态组件
### 模板管理页(5-7 天)
- [ ] 模板列表:卡片布局 + 搜索 + 分页
- [x] 三栏编辑器布局
- [x] 左栏:段落列表(点击高亮 + 滚动联动)
- [x] 中栏:文档预览(A4 纸样式,段落可点击选择)
- [x] 右栏:段落配置面板(编辑方式/模型/提示词/文件/格式)
- [x] 保存模板
### 模型管理页(2 天)
- [x] 模型卡片列表 + 启用/禁用
- [x] 添加/编辑弹窗表单
### 执行生成页(3 天)
- [x] 双栏布局:左模板选择 + 右段落列表
- [x] 文件上传区(按段落分列)
- [x] 生成按钮 + 进度展示
- [x] 完成跳转
### 生成记录页(2 天)
- [x] 统计卡片
- [ ] 卡片式历史列表 + 筛选
- [x] 预览/下载按钮
### 预览编辑页(3-4 天)
- [ ] 富文本编辑器(contenteditable + 自定义工具栏)
- [ ] AI 内容紫色高亮标注
- [ ] 重新生成单段落
- [x] 保存/导出按钮
## 第四阶段:联调与修边(第 5-6 周)
- [ ] 文件上传 → 解析 → 保存 → 生成 → 导出全流程联调
- [ ] 模板不同格式兼容性测试(不同字体、不同布局、含表格/图片/页眉页脚)
- [ ] AI 不同模型的输出格式兼容性
- [ ] 错误处理完善(网络中断、模型不可用、文件格式错误)
- [ ] 边界情况处理(空模板、超大文件、特殊字符)
- [ ] 响应式适配
- [ ] 加载状态/骨架屏
- [ ] 操作提示/Toast
## 阶段交付物
| 阶段 | 交付物 |
|------|--------|
| 第 1 周 | 项目脚手架、数据库表、API 接口文档 |
| 第 2-3 周 | 后端全部功能可用(可通过 API 测试) |
| 第 4-5 周 | 前端全部页面可用,可联调 |
| 第 6 周 | 全流程跑通,交付验收 |
@@ -0,0 +1,138 @@
# AI 文档模板生成系统 · 模板在线编辑重构任务拆解清单
总工期估算:4-6 周(两人并行:前端 + 后端)
## 第一阶段:方案定稿与数据结构升级(第 1 周)
### 产品方案与交互定稿(2-3 天)
- [ ] 明确“在线编辑模式 / AI 选区模式”双模式交互
- [ ] 明确块类型定义(标题块 / 正文块 / 表格块 / AI 块 / 变量块)
- [ ] 明确 AI 选区后的操作菜单(设为 AI / 设为固定内容 / 设为变量)
- [ ] 明确模板保存后的基线含义(保存即修改模板源内容)
- [ ] 明确导出时的填充规则(固定内容保留,AI 块填充,变量块替换)
- [ ] 输出交互原型说明文档
### 数据模型升级(2-3 天)
- [x] 新增模板块表 `template_blocks`
- [x] 定义块字段(block_type / sort_index / parent_id / anchor_ref / content_json / style_json / edit_mode
- [x] 为 AI 块补充字段(placeholder_key / model_id / prompt_text / need_file / output_format
- [x] 为变量块补充字段(variable_key / default_value
- [x] 保留旧 `paragraphs` 结构用于兼容过渡
- [x] 输出数据库增量 SQL
## 第二阶段:模板解析器重构(第 1-2 周)
### Word 块级解析(4-5 天)
- [x] 解析 `.docx` 为块流结构,而不只按 Heading 识别段落
- [x] 识别标题块
- [x] 识别普通正文块
- [x] 识别表格块
- [ ] 识别空白分隔块
- [ ] 为每个块记录原始位置索引与样式快照
- [x] 输出新的块级 JSON 结构
### 特殊区域识别(2-3 天)
- [ ] 识别封面区内容(标题 / 日期 / 负责人 / 单位等)
- [ ] 识别摘要区内容
- [ ] 识别未使用 Heading 的小节正文
- [x] 识别连续正文中的可拆分候选块
- [ ] 输出模板体检提示信息
## 第三阶段:模板在线编辑器重构(第 2-3 周)
### 中间编辑画布(4-5 天)
- [x] 将当前段落预览区改造为块级编辑画布
- [x] 支持标题块直接编辑
- [x] 支持正文块直接编辑
- [ ] 支持表格块展示与基础编辑
- [x] 支持块新增
- [x] 支持块删除
- [x] 支持块上移 / 下移
- [ ] 支持块拆分 / 合并
### 编辑模式与 AI 选区模式(3-4 天)
- [ ] 增加“在线编辑模式 / AI 选区模式”切换
- [ ] 在线编辑模式下支持直接修改模板内容
- [ ] AI 选区模式下支持鼠标选中一段内容
- [ ] 选中内容后可设为 AI 块
- [ ] 选中内容后可设为变量块
- [ ] 支持在当前位置后插入新的 AI 块
- [ ] 支持取消 AI 标记并恢复为固定内容
### 左右侧配置区联动(2-3 天)
- [x] 左侧块列表与中间编辑画布联动高亮
- [x] 右侧根据当前选中块显示不同配置项
- [x] AI 块显示模型 / 提示词 / 文件要求 / 输出格式配置
- [x] 变量块显示变量名 / 默认值配置
- [x] 固定块显示只读或基础编辑信息
## 第四阶段:模板保存与模板源同步写回(第 3 周)
### 模板保存链路重构(3-4 天)
- [ ] 保存模板时持久化块结构而非仅保存段落配置
- [ ] 保存模板时同步生成最新模板快照
- [ ] 将最新模板快照写回模板源 `.docx`
- [ ] 保存后重新加载模板时展示最新编辑结果
- [ ] 补充模板版本或快照记录
### 模板源写回引擎(3-4 天)
- [ ] 支持标题块写回
- [ ] 支持正文块写回
- [ ] 支持删除块后同步从模板源移除
- [ ] 支持移动块后同步更新模板顺序
- [ ] 支持新增块后同步插入模板源
- [ ] 处理第一页封面区块写回
## 第五阶段:生成链路适配(第 3-4 周)
### AI 生成任务改造(3-4 天)
- [ ] 生成任务由“按段落”改为“按 AI 块”
- [ ] 固定块不参与 AI 生成
- [ ] 变量块按规则填充值
- [ ] AI 块支持单块测试生成
- [ ] AI 块支持多块批量生成
- [ ] AI 块支持失败回退与重试
### 文件与提示词链路适配(2-3 天)
- [ ] 文件上传改为绑定 AI 块
- [ ] 历史文件复用继续兼容
- [ ] 提示词配置迁移到 AI 块级别
- [ ] 输出格式配置迁移到 AI 块级别
## 第六阶段:导出引擎重构(第 4-5 周)
### Word 导出写回(4-5 天)
- [ ] 导出基于“最新模板快照”而不是原始导入模板
- [ ] 固定块保持模板编辑后的最终内容
- [ ] AI 块按占位位置写回生成结果
- [ ] 变量块按变量值替换
- [ ] 支持同一位置下多块顺序写回
- [ ] 支持文字与表格混排导出
- [ ] 保留主要样式与段落结构
### 导出正确性校验(2-3 天)
- [ ] 校验删除块后导出无残留
- [ ] 校验调整顺序后导出顺序正确
- [ ] 校验第一页内容导出正确
- [ ] 校验摘要区内容导出正确
- [ ] 校验多级标题结构导出正确
## 第七阶段:联调、修边与验收(第 5-6 周)
- [ ] 用真实复杂模板联调(含封面 / 摘要 / 多级标题 / 表格)
- [ ] 验证第一页块可删除、可调整、可导出
- [ ] 验证保存模板后再次进入能看到最新模板内容
- [ ] 验证 AI 选区转块后的生成与导出闭环
- [ ] 验证历史生成记录兼容旧数据
- [ ] 完善错误提示、加载状态与操作反馈
- [ ] 补充模板编辑使用说明
## 阶段交付物
| 阶段 | 交付物 |
|------|--------|
| 第 1 周 | 在线编辑方案、块模型设计、数据库增量方案 |
| 第 2 周 | 新版模板解析器、块级 JSON 结构、模板体检提示 |
| 第 3 周 | 块级在线编辑器、AI 选区模式、模板保存链路 |
| 第 4-5 周 | AI 块生成链路、导出写回引擎、真实模板导出闭环 |
| 第 6 周 | 全流程联调通过、使用说明与验收结果 |
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS template_blocks (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
source_paragraph_id INT DEFAULT NULL COMMENT '来源段落 ID',
parent_block_id INT DEFAULT NULL COMMENT '父块 ID',
sort_index INT DEFAULT 0 COMMENT '排序',
block_type VARCHAR(30) DEFAULT 'text' COMMENT 'heading/text/table/ai_slot/variable',
anchor_ref VARCHAR(500) DEFAULT '' COMMENT '原始锚点引用',
title VARCHAR(500) DEFAULT '' COMMENT '块标题',
content_json TEXT DEFAULT '{}' COMMENT '块内容 JSON',
style_json TEXT DEFAULT '{}' COMMENT '块样式 JSON',
edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai',
placeholder_key VARCHAR(120) DEFAULT '' COMMENT 'AI 占位键',
variable_key VARCHAR(120) DEFAULT '' COMMENT '变量键',
default_value TEXT DEFAULT '' COMMENT '默认值',
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,
CONSTRAINT fk_template_blocks_template FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
CONSTRAINT fk_template_blocks_paragraph FOREIGN KEY (source_paragraph_id) REFERENCES paragraphs(id) ON DELETE SET NULL,
CONSTRAINT fk_template_blocks_parent FOREIGN KEY (parent_block_id) REFERENCES template_blocks(id) ON DELETE SET NULL,
CONSTRAINT fk_template_blocks_model FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+119
View File
@@ -0,0 +1,119 @@
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',
supports_streaming TINYINT(1) DEFAULT 0 COMMENT '是否支持流式传输',
enable_reasoning TINYINT(1) DEFAULT 0 COMMENT '是否开启思考模式',
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 '排序',
anchor_title VARCHAR(500) DEFAULT '' 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 'manual' COMMENT 'manual/ai',
write_mode VARCHAR(30) DEFAULT 'replace_section' COMMENT 'replace_section/append_after_heading/replace_heading_only',
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 template_blocks (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
source_paragraph_id INT DEFAULT NULL COMMENT '来源段落 ID',
parent_block_id INT DEFAULT NULL COMMENT '父块 ID',
sort_index INT DEFAULT 0 COMMENT '排序',
block_type VARCHAR(30) DEFAULT 'text' COMMENT 'heading/text/table/ai_slot/variable',
anchor_ref VARCHAR(500) DEFAULT '' COMMENT '原始锚点引用',
title VARCHAR(500) DEFAULT '' COMMENT '块标题',
content_json TEXT DEFAULT '{}' COMMENT '块内容 JSON',
style_json TEXT DEFAULT '{}' COMMENT '块样式 JSON',
edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai',
placeholder_key VARCHAR(120) DEFAULT '' COMMENT 'AI 占位键',
variable_key VARCHAR(120) DEFAULT '' COMMENT '变量键',
default_value TEXT DEFAULT '' COMMENT '默认值',
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 (source_paragraph_id) REFERENCES paragraphs(id) ON DELETE SET NULL,
FOREIGN KEY (parent_block_id) REFERENCES template_blocks(id) ON DELETE SET NULL,
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 '错误信息',
request_payload_json TEXT 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;
CREATE TABLE IF NOT EXISTS reference_files (
id INT AUTO_INCREMENT PRIMARY KEY,
file_name VARCHAR(255) DEFAULT '' COMMENT '原始文件名',
file_path VARCHAR(500) DEFAULT '' COMMENT 'MinIO 对象路径',
file_size INT DEFAULT 0 COMMENT '文件大小',
content_type VARCHAR(120) DEFAULT '' COMMENT '文件类型',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) 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
+176
View File
@@ -0,0 +1,176 @@
<template>
<a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }">
<div class="app-shell">
<header class="topbar">
<div class="topbar-logo">
<div class="logo-icon">A</div>
<span>AI 文档模板</span>
</div>
<nav class="topbar-nav">
<button :class="['topbar-tab', { active: isActive('/templates') }]" @click="router.push('/templates')">
<folder-outlined />
模板管理
</button>
<button :class="['topbar-tab', { active: isActive('/models') }]" @click="router.push('/models')">
<api-outlined />
模型管理
</button>
<button :class="['topbar-tab', { active: isActive('/generate') }]" @click="router.push('/generate')">
<thunderbolt-outlined />
执行生成
</button>
<button :class="['topbar-tab', { active: isActive('/attachments') }]" @click="router.push('/attachments')">
<paper-clip-outlined />
附件历史
</button>
<button :class="['topbar-tab', { active: isActive('/history') }]" @click="router.push('/history')">
<clock-circle-outlined />
生成记录
</button>
</nav>
<div class="topbar-right">
<span class="version-text">v3.0</span>
<div class="user-badge">
<span class="user-name">周文涛</span>
<div class="user-avatar-sm"></div>
</div>
</div>
</header>
<main class="page-main">
<router-view />
</main>
</div>
</a-config-provider>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
function isActive(path: string) {
return route.path.startsWith(path)
}
</script>
<style scoped>
.app-shell {
min-height: 100vh;
background: #f5f6f8;
color: #1a1d24;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
}
.topbar {
height: 52px;
background: #fff;
border-bottom: 1px solid #e0e2e6;
display: flex;
align-items: center;
padding: 0 24px;
gap: 12px;
}
.topbar-logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
margin-right: 16px;
flex-shrink: 0;
}
.logo-icon {
width: 28px;
height: 28px;
background: #5b5bd6;
border-radius: 6px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
}
.topbar-nav {
display: flex;
align-items: center;
gap: 4px;
}
.topbar-tab {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
color: #5b626e;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.12s;
}
.topbar-tab:hover {
background: #f0f1f3;
color: #1a1d24;
}
.topbar-tab.active {
background: #eeeefb;
color: #5b5bd6;
font-weight: 500;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.version-text {
font-size: 12px;
color: #9aa1ad;
}
.user-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 12px;
background: #f0f1f3;
border-radius: 20px;
}
.user-name {
font-size: 12px;
font-weight: 500;
}
.user-avatar-sm {
width: 22px;
height: 22px;
border-radius: 50%;
background: #eeeefb;
color: #5b5bd6;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.page-main {
height: calc(100vh - 52px);
overflow: auto;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
import http from './index'
export const generateApi = {
test: (data: any) => http.post('/generate/test', data),
testStream: () => '/api/v1/generate/test-stream',
upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }),
updateReferenceFile: (id: number, data: any) => http.put(`/generate/reference-files/${id}`, data),
deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`),
downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`),
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
+10
View File
@@ -0,0 +1,10 @@
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`),
balance: (id: number) => http.get(`/models/${id}/balance`),
}
+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: File)=>{$emit('upload',f);return false}" :showUploadList="showList"><p class="ant-upload-drag-icon"><upload-outlined /></p><p class="ant-upload-text">{{text}}</p><p class="ant-upload-hint">{{hint}}</p></a-upload-dragger></template>
<script setup lang="ts">
import { UploadOutlined } from "@ant-design/icons-vue";
defineProps<{text?:string;hint?:string;multiple?:boolean;showList?:boolean}>()
defineEmits<{upload:[file:File]}>()
</script>
+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>
@@ -0,0 +1,303 @@
<template>
<div :class="['reference-selector', variant]">
<div v-if="variant === 'full'" class="upload-card">
<div v-if="title" class="upload-title">{{ title }}</div>
<div v-if="description" class="upload-desc">{{ description }}</div>
<a-upload-dragger :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
</a-upload-dragger>
</div>
<div v-else class="compact-toolbar">
<a-upload :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<a-button size="small" :loading="uploading">{{ hasSelection ? '继续上传' : '上传文件' }}</a-button>
</a-upload>
<a-button size="small" @click="toggleHistoryPanel">{{ historyPanelOpen ? '收起历史文件' : '选择历史文件' }}</a-button>
</div>
<div v-if="selectedFiles.length" class="selected-list">
<div class="selected-item" v-for="item in selectedFiles" :key="item.file_path">
<span class="selected-name">{{ item.file_name }}</span>
<a-button type="link" size="small" danger @click="removeSelectedFile(item.file_path)">
{{ variant === 'full' ? '移除' : 'x' }}
</a-button>
</div>
</div>
<div v-if="showHistorySection" class="history-card">
<div class="history-head">
<div>
<div class="history-title">历史文件</div>
<div class="history-desc">已上传过的文件会保存在系统里下次可直接选择复用</div>
</div>
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
</div>
<div class="history-search">
<a-input-search
v-model:value="historyKeyword"
placeholder="按文件名搜索历史文件"
allow-clear
@search="fetchReferenceHistory"
/>
</div>
<a-spin :spinning="historyLoading">
<div v-if="referenceHistory.length" class="history-list">
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
<input
type="checkbox"
:checked="isSelected(item.file_path)"
@change="toggleHistoryFile(item)"
/>
<div class="history-item-main">
<div class="history-item-name">{{ item.file_name }}</div>
<div class="history-item-meta">
<span>{{ formatFileSize(item.file_size) }}</span>
<span>{{ formatDateTime(item.created_at) }}</span>
</div>
</div>
</label>
</div>
<a-empty v-else description="暂无历史文件" />
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined } from '@ant-design/icons-vue'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
type SelectedFile = { file_name: string; file_path: string }
const props = withDefaults(defineProps<{
modelValue: SelectedFile[]
title?: string
description?: string
variant?: 'full' | 'compact'
}>(), {
title: '',
description: '',
variant: 'full',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: SelectedFile[]): void
}>()
const uploading = ref(false)
const referenceHistory = ref<ReferenceFile[]>([])
const historyKeyword = ref('')
const historyLoading = ref(false)
const historyPanelOpen = ref(false)
const selectedFiles = computed(() => props.modelValue || [])
const hasSelection = computed(() => selectedFiles.value.length > 0)
const showHistorySection = computed(() => props.variant === 'full' || historyPanelOpen.value)
function updateFiles(files: SelectedFile[]) {
emit('update:modelValue', files)
}
function appendSelectedFile(file: SelectedFile) {
if (selectedFiles.value.some((item) => item.file_path === file.file_path)) return
updateFiles([...selectedFiles.value, file])
}
function removeSelectedFile(filePath: string) {
updateFiles(selectedFiles.value.filter((item) => item.file_path !== filePath))
}
function isSelected(filePath: string) {
return selectedFiles.value.some((item) => item.file_path === filePath)
}
function toggleHistoryFile(item: ReferenceFile) {
if (isSelected(item.file_path)) {
removeSelectedFile(item.file_path)
return
}
appendSelectedFile({ file_name: item.file_name, file_path: item.file_path })
}
async function beforeUpload(file: File) {
try {
uploading.value = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
appendSelectedFile({ file_name: res.data.file_name, file_path: res.data.file_path })
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploading.value = false
}
return false
}
async function fetchReferenceHistory() {
historyLoading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: 1,
page_size: 30,
keyword: historyKeyword.value.trim(),
})
referenceHistory.value = response.data?.items || []
} catch (error: any) {
message.error(error.message || '加载历史文件失败')
} finally {
historyLoading.value = false
}
}
function toggleHistoryPanel() {
historyPanelOpen.value = !historyPanelOpen.value
if (historyPanelOpen.value && !referenceHistory.value.length) {
fetchReferenceHistory()
}
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
onMounted(() => {
if (props.variant === 'full') {
fetchReferenceHistory()
}
})
</script>
<style scoped>
.reference-selector {
display: grid;
gap: 16px;
}
.upload-card {
background: #f0f1f3;
border-radius: 8px;
padding: 16px;
}
.upload-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.upload-desc {
font-size: 12px;
color: #5b626e;
margin-bottom: 12px;
}
.compact-toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
}
.history-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.history-desc {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.history-search {
margin: 12px 0;
}
.history-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow-y: auto;
}
.history-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
cursor: pointer;
}
.history-item-main {
min-width: 0;
flex: 1;
}
.history-item-name {
font-size: 13px;
font-weight: 500;
color: #111827;
word-break: break-all;
}
.history-item-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.selected-list {
display: grid;
gap: 8px;
}
.selected-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: #f5f6f8;
border-radius: 8px;
padding: 8px 12px;
}
.selected-name {
min-width: 0;
font-size: 13px;
word-break: break-all;
}
</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: 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";
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')
+15
View File
@@ -0,0 +1,15 @@
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: '/attachments', name: 'AttachmentHistoryPage', component: () => import('@/views/AttachmentHistoryPage.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 }
})
+18
View File
@@ -0,0 +1,18 @@
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) }
async function balance(id: number) { return await modelApi.balance(id) }
return { models, loading, fetchList, create, update, remove, test, balance }
})
+45
View File
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { templateApi } from '@/api/template'
import type { Template, Paragraph, TemplateBlock } from '@/types'
export const useTemplateStore = defineStore('template', () => {
const templates = ref<Template[]>([])
const currentTemplate = ref<Template | null>(null)
const paragraphs = ref<Paragraph[]>([])
const blocks = ref<TemplateBlock[]>([])
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 || []; blocks.value = r.data?.blocks || []; 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 || []; blocks.value = r.data?.blocks || []; return r.data }
async function save(id: number) {
const data = paragraphs.value.map((p, index) => ({ id: p.id, sort_index: index + 1, anchor_title: p.anchor_title, title: p.title, content: p.content, edit_mode: p.edit_mode, write_mode: p.write_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 }))
const blockData = blocks.value.map((block, index) => ({
id: block.id,
source_paragraph_id: block.source_paragraph_id,
parent_block_id: block.parent_block_id,
sort_index: index + 1,
block_type: block.block_type,
anchor_ref: block.anchor_ref,
title: block.title,
content_json: block.content_json,
style_json: block.style_json,
edit_mode: block.edit_mode,
placeholder_key: block.placeholder_key,
variable_key: block.variable_key,
default_value: block.default_value,
model_id: block.model_id,
need_prompt: block.need_prompt,
prompt_text: block.prompt_text,
need_file: block.need_file,
file_note: block.file_note,
output_format: block.output_format,
}))
const r: any = await templateApi.saveParagraphs(id, { save_mode: 'manual', paragraphs: data, blocks: blockData })
blocks.value = r.data?.blocks || blocks.value
}
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
return { templates, currentTemplate, paragraphs, blocks, loading, fetchList, fetchOne, upload, save, remove }
})
+69
View File
@@ -0,0 +1,69 @@
export interface Template {
id: number; name: string; description: string; file_path: string
paragraph_count: number; status: string; created_at: string; updated_at: string
blocks?: TemplateBlock[]
}
export interface Paragraph {
id: number; template_id: number; sort_index: number; title: string; content: string
anchor_title: string
style_json: string; is_table: boolean; table_json: string
edit_mode: 'manual' | 'ai'; model_id: number | null
write_mode: 'replace_section' | 'append_after_heading' | 'replace_heading_only'
need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string
output_format: 'text' | 'table' | 'mixed' | 'chart'
}
export interface TemplateBlock {
id: number
template_id?: number
source_paragraph_id?: number | null
parent_block_id?: number | null
sort_index: number
block_type: 'heading' | 'text' | 'table' | 'ai_slot' | 'variable'
anchor_ref: string
title: string
content_json: Record<string, any>
style_json: string
edit_mode: 'manual' | 'ai'
placeholder_key: string
variable_key: string
default_value: string
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; supports_streaming: boolean; enable_reasoning: boolean; 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
request_payload?: {
template_name?: string
file_map?: Record<string, string[]>
paragraphs?: Array<{
paragraph_id: number
title: string
sort_index: number
need_file: boolean
file_note: string
selected_files: ReferenceFile[]
}>
}
}
export interface ReferenceFile {
id: number; file_name: string; file_path: string; file_size: number; content_type: string; created_at: 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 }
+256
View File
@@ -0,0 +1,256 @@
<template>
<div class="attachment-page">
<div class="page-header">
<div>
<div class="page-title">附件历史</div>
<div class="page-desc">查看系统内已上传的参考文件便于后续模板测试时直接复用</div>
</div>
<a-button @click="fetchList">刷新列表</a-button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">历史附件总数</div>
<div class="stat-value">{{ total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">当前页文件数</div>
<div class="stat-value">{{ files.length }}</div>
</div>
</div>
<div class="toolbar">
<a-input-search
v-model:value="keyword"
placeholder="按文件名搜索附件"
allow-clear
@search="handleSearch"
/>
</div>
<a-card :bordered="false" class="table-card">
<a-table
:columns="columns"
:data-source="files"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'file_name'">
<div class="file-name">{{ record.file_name }}</div>
<div class="file-path">{{ record.file_path }}</div>
</template>
<template v-else-if="column.key === 'file_size'">
{{ formatFileSize(record.file_size) }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="downloadFile(record)">下载</a-button>
<a-button size="small" @click="renameFile(record)">改名</a-button>
<a-button size="small" danger @click="removeFile(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
const loading = ref(false)
const keyword = ref('')
const files = ref<ReferenceFile[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const columns = [
{ title: '文件名', key: 'file_name', dataIndex: 'file_name' },
{ title: '文件类型', key: 'content_type', dataIndex: 'content_type', width: 180 },
{ title: '文件大小', key: 'file_size', dataIndex: 'file_size', width: 120 },
{ title: '上传时间', key: 'created_at', dataIndex: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 220 },
]
const pagination = ref({
current: page.value,
pageSize: pageSize.value,
total: total.value,
showSizeChanger: true,
showTotal: (count: number) => `${count} 个附件`,
})
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
async function fetchList() {
loading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: page.value,
page_size: pageSize.value,
keyword: keyword.value.trim(),
})
files.value = response.data?.items || []
total.value = response.data?.total || 0
pagination.value = {
...pagination.value,
current: response.data?.page || page.value,
pageSize: response.data?.page_size || pageSize.value,
total: response.data?.total || 0,
}
} catch (error: any) {
message.error(error.message || '加载附件历史失败')
} finally {
loading.value = false
}
}
async function downloadFile(record: ReferenceFile) {
try {
const response: any = await generateApi.downloadReferenceFile(record.id)
window.open(response.data?.url, '_blank')
} catch (error: any) {
message.error(error.message || '下载附件失败')
}
}
function renameFile(record: ReferenceFile) {
const nextName = window.prompt('请输入新的附件名称', record.file_name)
if (nextName === null) return
if (!nextName.trim()) {
message.warning('文件名不能为空')
return
}
generateApi.updateReferenceFile(record.id, { file_name: nextName.trim() }).then(async () => {
message.success('附件名称已更新')
await fetchList()
}).catch((error: any) => {
message.error(error.message || '附件改名失败')
})
}
function removeFile(record: ReferenceFile) {
Modal.confirm({
title: '删除附件',
content: `确认删除附件“${record.file_name}”吗?`,
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
await generateApi.deleteReferenceFile(record.id)
message.success('附件已删除')
await fetchList()
},
})
}
function handleSearch() {
page.value = 1
fetchList()
}
function handleTableChange(nextPagination: any) {
page.value = nextPagination.current || 1
pageSize.value = nextPagination.pageSize || 10
fetchList()
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.attachment-page {
padding: 24px;
background: #f5f6f8;
min-height: 100%;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: #111827;
}
.page-desc {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
padding: 18px 20px;
border-radius: 16px;
background: linear-gradient(135deg, #ffffff 0%, #f5f7fb 100%);
border: 1px solid #e5e7eb;
}
.stat-label {
font-size: 13px;
color: #6b7280;
}
.stat-value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: #111827;
}
.toolbar {
margin-bottom: 16px;
max-width: 360px;
}
.table-card {
border-radius: 18px;
}
.file-name {
font-size: 14px;
font-weight: 600;
color: #111827;
word-break: break-all;
}
.file-path {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
word-break: break-all;
}
</style>
+430
View File
@@ -0,0 +1,430 @@
<template>
<div class="page-wrap">
<div class="breadcrumb">
<span>执行生成</span>
<span>/</span>
<span>选择模板 上传文件 AI 自动生成</span>
</div>
<div class="gen-layout">
<div class="gen-left">
<div class="panel-card">
<div class="panel-head">
<div class="panel-title">选择模板</div>
</div>
<div class="panel-body">
<a-select style="width:100%" v-model:value="selectedTplId" placeholder="— 请选择已编辑好的模板 —" @change="onTplChange">
<a-select-option v-for="item in templates" :key="item.id" :value="item.id">{{ item.name }}</a-select-option>
</a-select>
<div class="template-summary" v-if="selectedTplId">
<div class="summary-name">{{ currentTemplateName }}</div>
<div class="summary-desc">{{ tplInfo.paragraph_count || 0 }} 个段落 · {{ needFileCount }} 个需上传文件</div>
</div>
<div class="summary-stats">
<div class="stat-box">
<div class="stat-num">{{ tplInfo.paragraph_count || 0 }}</div>
<div class="stat-label">总段落</div>
</div>
<div class="stat-box success">
<div class="stat-num">{{ autoCount }}</div>
<div class="stat-label">自动生成</div>
</div>
<div class="stat-box warning">
<div class="stat-num">{{ needFileCount }}</div>
<div class="stat-label">需要文件</div>
</div>
</div>
</div>
</div>
<div class="status-block">
<div class="gen-progress">
<div class="gen-text">
这里仅负责提交生成任务任务创建后会在后台继续执行你可以离开当前页面稍后在生成记录或任务详情中查看进度
</div>
</div>
</div>
</div>
<div class="gen-right">
<div class="panel-card fill-card">
<div class="panel-head">
<div class="panel-title">段落文件配置</div>
</div>
<div class="panel-body">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]">
<div class="para-info">
<span class="idx">{{ paragraph.sort_index }}</span>
<div class="para-main">
<span>{{ paragraph.title }}</span>
<span v-if="paragraph.need_file && paragraph.file_note" class="file-note">{{ paragraph.file_note }}</span>
</div>
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div>
<div v-if="paragraph.need_file" class="file-info">
<ReferenceFileSelector
v-model="uploadedFiles[paragraph.id]"
variant="compact"
/>
</div>
<span v-else class="no-file-tag">无需上传</span>
</div>
</div>
</div>
<div class="action-bar">
<span>{{ fileCount }}/{{ needFileCount }} 个文件已上传</span>
<a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template'
import { useDocumentStore } from '@/stores/document'
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute()
const router = useRouter()
const tplStore = useTemplateStore()
const docStore = useDocumentStore()
const templates = ref<any[]>([])
const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
const generating = ref(false)
const tplInfo = ref<any>({})
const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length)
const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length)
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
watch(
uploadedFiles,
(value) => {
const filePaths = Object.fromEntries(
Object.entries(value).map(([key, items]) => [Number(key), (items || []).map((item) => item.file_path)])
)
uploadedFilePaths.value = filePaths
},
{ deep: true }
)
const uploadedFilePaths = ref<Record<number, string[]>>({})
async function refreshTemplates() {
await tplStore.fetchList()
templates.value = tplStore.templates as any
}
onMounted(async () => {
await refreshTemplates()
const queryId = Number(route.query.templateId)
if (queryId) {
selectedTplId.value = queryId
await onTplChange(queryId)
}
})
async function onTplChange(id: number) {
const template = await tplStore.fetchOne(id)
paragraphs.value = tplStore.paragraphs as any
tplInfo.value = {
paragraph_count: template.paragraph_count,
fileCount: paragraphs.value.filter((item: any) => item.need_file).length,
}
uploadedFiles.value = {}
uploadedFilePaths.value = {}
}
async function startGen() {
if (!selectedTplId.value) {
message.warning('请先选择模板')
return
}
const missing = paragraphs.value.filter((item: any) => item.need_file && !uploadedFilePaths.value[item.id])
if (missing.length) {
message.warning('还有必传文件未上传')
return
}
generating.value = true
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value || []]))
try {
const document: any = await docStore.generateFull({ template_id: selectedTplId.value, file_map: fileMap })
message.success('生成任务已提交,已转到任务详情页继续查看进度')
router.push(`/preview/${document.id}`)
} catch (error: any) {
generating.value = false
message.error(error.message || '生成失败')
return
}
generating.value = false
}
</script>
<style scoped>
.page-wrap {
padding: 24px 32px 32px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9aa1ad;
margin-bottom: 16px;
}
.gen-layout {
display: flex;
gap: 24px;
min-height: calc(100vh - 140px);
}
.gen-left {
width: 340px;
flex-shrink: 0;
}
.gen-right {
flex: 1;
display: flex;
flex-direction: column;
}
.panel-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e0e2e6;
overflow: hidden;
}
.fill-card {
flex: 1;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #eaecef;
}
.panel-title {
font-size: 15px;
font-weight: 600;
}
.panel-body {
padding: 24px;
}
.template-summary {
background: #f0f1f3;
border-radius: 8px;
padding: 12px;
margin-top: 12px;
}
.summary-name {
font-weight: 500;
margin-bottom: 4px;
}
.summary-desc {
font-size: 12px;
color: #9aa1ad;
}
.summary-stats {
display: flex;
gap: 12px;
margin-top: 12px;
}
.stat-box {
flex: 1;
text-align: center;
padding: 8px;
background: #f0f1f3;
border-radius: 6px;
}
.stat-box.success {
background: #e8f5e9;
}
.stat-box.warning {
background: #fff3e0;
}
.stat-num {
font-size: 18px;
font-weight: 700;
color: #5b5bd6;
}
.stat-label {
font-size: 12px;
color: #9aa1ad;
}
.status-block {
margin-top: 16px;
}
.gen-progress {
background: #fff;
border: 1px solid #e0e2e6;
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 16px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e2e6;
border-top-color: #5b5bd6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
.gen-text {
font-size: 13px;
color: #5b626e;
}
.progress-card {
background: #fff;
border: 1px solid #e0e2e6;
border-radius: 8px;
padding: 16px;
margin-top: 12px;
}
.progress-title {
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
}
.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: 0.7;
}
.para-info {
display: flex;
align-items: flex-start;
gap: 8px;
}
.para-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-note {
font-size: 12px;
color: #6b7280;
}
.file-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.uploaded-list {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.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;
}
.uploaded-name {
color: #1a8c4a;
border: 1px solid #cdebd8;
background: #eefbf2;
padding: 4px 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.remove-file-btn {
border: none;
background: transparent;
color: #999;
cursor: pointer;
padding: 0;
}
.no-file-tag {
font-size: 12px;
color: #999;
}
.action-bar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div style="padding:24px">
<a-row :gutter="16" style="margin-bottom:24px">
<a-col :span="6"><a-card><a-statistic title="总生成" :value="totalCount" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="成功" :value="completedCount" value-style="color:#52c41a" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="中断" :value="cancelledCount" value-style="color:#faad14" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="失败" :value="failedCount" 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-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="statusBadge(item.status)" :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 { computed, 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[]>([])
const totalCount = computed(() => documents.value.length)
const completedCount = computed(() => documents.value.filter((item) => item.status === 'completed').length)
const cancelledCount = computed(() => documents.value.filter((item) => item.status === 'cancelled').length)
const failedCount = computed(() => documents.value.filter((item) => item.status === 'failed').length)
function statusText(status: string) {
const map: Record<string, string> = {
completed: '已生成',
failed: '失败',
cancelled: '中断',
generating: '生成中',
pending: '等待中',
}
return map[status] || status
}
function statusBadge(status: string) {
if (status === 'completed') return 'success'
if (status === 'failed') return 'error'
if (status === 'cancelled') return 'warning'
return 'processing'
}
function preview(id: number) {
router.push(`/preview/${id}`)
}
function download(id: number) {
window.open(generateApi.exportDocx(id))
}
onMounted(async () => {
await store.fetchList()
documents.value = store.documents as any
})
</script>
+299
View File
@@ -0,0 +1,299 @@
<template>
<div class="page-wrap">
<div class="breadcrumb">
<span>模型管理</span>
<span>/</span>
<span>AI 模型配置</span>
</div>
<div class="panel-card">
<div class="panel-head">
<div class="panel-title">可用模型列表</div>
<a-button @click="openAdd">添加模型</a-button>
</div>
<div class="panel-body">
<div class="model-card" v-for="item in models" :key="item.id">
<div class="mc-icon">{{ item.name?.slice(0, 1)?.toUpperCase() || 'M' }}</div>
<div class="mc-info">
<div class="mc-name">{{ item.name }}</div>
<div class="mc-provider">{{ item.provider }} · {{ item.api_endpoint }}</div>
<div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
<div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div>
<div class="mc-provider">思考模式{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
<div v-if="isDeepSeek(item) && balanceMap[item.id]" class="mc-provider">
余额{{ formatBalanceText(balanceMap[item.id]) }}
</div>
</div>
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
</div>
<div class="mc-actions">
<a-button size="small" :loading="testingMap[item.id]" @click="runTest(item)">
<template #icon><reload-outlined /></template>
刷新测试
</a-button>
<a-button v-if="isDeepSeek(item)" size="small" :loading="balanceLoadingMap[item.id]" @click="fetchBalance(item)">
查看余额
</a-button>
<a-button size="small" @click="openEdit(item)">编辑</a-button>
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
</div>
</div>
</div>
</div>
<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-select v-model:value="providerPreset" @change="applyProviderPreset">
<a-select-option value="deepseek">DeepSeek</a-select-option>
<a-select-option value="custom">自定义</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="providerPreset === 'custom'" label="自定义厂商名称">
<a-input v-model:value="form.provider" placeholder="例如 OpenAI / Anthropic / 其他" />
</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="支持流式传输">
<a-switch v-model:checked="form.supports_streaming" />
</a-form-item>
<a-form-item label="开启思考模式">
<a-switch v-model:checked="form.enable_reasoning" />
</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 { message } from 'ant-design-vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { useModelStore } from '@/stores/model'
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: '', supports_streaming: false, enable_reasoning: false })
const providerPreset = ref<'deepseek' | 'custom'>('custom')
const testingMap = ref<Record<number, boolean>>({})
const balanceLoadingMap = ref<Record<number, boolean>>({})
const balanceMap = ref<Record<number, { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }>>({})
function isDeepSeek(item: any) {
const provider = (item?.provider || '').trim().toLowerCase()
return provider === 'deepseek'
}
function applyProviderPreset(value: 'deepseek' | 'custom') {
if (value === 'deepseek') {
form.value.provider = 'DeepSeek'
form.value.api_format = 'openai'
if (!form.value.api_endpoint || form.value.api_endpoint.includes('deepseek')) {
form.value.api_endpoint = 'https://api.deepseek.com'
}
return
}
if (form.value.provider === 'DeepSeek') {
form.value.provider = ''
}
}
function inferProviderPreset(item?: any) {
providerPreset.value = isDeepSeek(item || form.value) ? 'deepseek' : 'custom'
}
function formatBalanceText(data: { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }) {
const infos = data?.balance_infos || []
if (!infos.length) return data?.is_available ? '可用' : '不可用'
return infos
.map((item) => `${item.currency} ${item.total_balance}(充值 ${item.topped_up_balance} / 赠送 ${item.granted_balance}`)
.join('')
}
async function refreshList() {
await store.fetchList()
models.value = store.models as any
}
onMounted(async () => {
await refreshList()
})
function openAdd() {
isEdit.value = false
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
providerPreset.value = 'deepseek'
applyProviderPreset('deepseek')
modalOpen.value = true
}
function openEdit(item: any) {
isEdit.value = true
editId.value = item.id
form.value = {
name: item.name,
provider: item.provider,
api_format: item.api_format,
api_endpoint: item.api_endpoint,
api_key: '',
supports_streaming: !!item.supports_streaming,
enable_reasoning: !!item.enable_reasoning,
}
inferProviderPreset(item)
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
await refreshList()
message.success('保存成功')
}
async function toggleStatus(item: any) {
await store.update(item.id, { status: item.status === 'enabled' ? 'disabled' : 'enabled' })
await refreshList()
message.success('状态已更新')
}
async function runTest(item: any) {
testingMap.value[item.id] = true
try {
const result: any = await store.test(item.id)
message.success(result.data?.message || `模型 ${item.name} 测试成功`, 2)
} catch (error: any) {
message.error(error.message || '连接测试失败', 2)
} finally {
testingMap.value[item.id] = false
}
}
async function fetchBalance(item: any) {
balanceLoadingMap.value[item.id] = true
try {
const result: any = await store.balance(item.id)
balanceMap.value[item.id] = result.data
message.success(`已刷新 ${item.name} 余额`, 2)
} catch (error: any) {
message.error(error.message || '余额查询失败', 2)
} finally {
balanceLoadingMap.value[item.id] = false
}
}
</script>
<style scoped>
.page-wrap {
padding: 24px 32px 32px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9aa1ad;
margin-bottom: 16px;
}
.panel-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e0e2e6;
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #eaecef;
}
.panel-title {
font-size: 15px;
font-weight: 600;
}
.panel-body {
padding: 24px;
}
.model-card {
border: 1px solid #eaecef;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
}
.mc-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: #eeeefb;
color: #5b5bd6;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
flex-shrink: 0;
}
.mc-info {
flex: 1;
min-width: 0;
}
.mc-name {
font-size: 14px;
font-weight: 500;
}
.mc-provider {
font-size: 12px;
color: #9aa1ad;
}
.mc-status {
font-size: 12px;
white-space: nowrap;
}
.mc-status.on {
color: #1a8c4a;
}
.mc-status.off {
color: #9aa1ad;
}
.mc-actions {
display: flex;
gap: 8px;
}
</style>
+491
View File
@@ -0,0 +1,491 @@
<template>
<div class="detail-page">
<div class="detail-head">
<div>
<div class="detail-title">任务详情</div>
<div class="detail-desc">查看当前生成状态已选附件以及已完成段落的输出结果</div>
</div>
<div class="detail-actions">
<a-button v-if="isRunning" danger @click="cancelTask">取消任务</a-button>
<a-button :disabled="!isCompleted" @click="exportDocx">导出 Word</a-button>
<a-button :disabled="!isCompleted" @click="exportPdf">导出 PDF</a-button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">任务状态</div>
<div class="stat-value">{{ statusText(documentInfo.status) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">完成进度</div>
<div class="stat-value">{{ documentInfo.para_count_done || 0 }}/{{ documentInfo.para_count_total || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">关联模板</div>
<div class="stat-value">{{ documentInfo.request_payload?.template_name || documentInfo.name || '-' }}</div>
</div>
</div>
<a-card class="status-card">
<a-badge :status="statusBadge(documentInfo.status)" :text="statusText(documentInfo.status)" />
<a-progress style="margin-top: 14px" :percent="progressPercent" />
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
</a-card>
<div class="detail-layout">
<aside class="detail-left">
<a-card class="left-card" title="段落列表">
<div v-if="paragraphMappings.length" class="mapping-list">
<div
v-for="item in paragraphMappings"
:key="item.paragraph_id"
:class="['mapping-item', { active: selectedParagraphId === item.paragraph_id }]"
@click="selectParagraph(item.paragraph_id)"
>
<div class="mapping-top">
<span class="mapping-index">{{ item.sort_index }}</span>
<div class="mapping-main">
<div class="mapping-title">{{ item.title }}</div>
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
</div>
</div>
<div class="mapping-status">
<a-badge :status="statusBadge(logStatusMap[item.paragraph_id] || 'pending')" :text="statusText(logStatusMap[item.paragraph_id] || 'pending')" />
</div>
<div v-if="item.selected_files?.length" class="mapping-files">
<span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div>
<div v-else class="mapping-empty">未选择附件</div>
</div>
</div>
<a-empty v-else description="当前任务未记录段落信息" />
</a-card>
</aside>
<section class="detail-right">
<a-card class="preview-card" title="生成结果预览">
<template v-if="selectedParagraph">
<div class="preview-section">
<div class="preview-section-head">
<h3>{{ selectedParagraph.title }}</h3>
<a-badge :status="statusBadge(selectedParagraph.status)" :text="statusText(selectedParagraph.status)" />
</div>
<div v-if="selectedMapping?.file_note" class="preview-note">文件要求{{ selectedMapping.file_note }}</div>
<div v-if="selectedMapping?.selected_files?.length" class="preview-files">
<span v-for="file in selectedMapping.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div>
<div class="preview-block" v-html="renderBlocks(selectedParagraph.content?.content || [])" />
</div>
</template>
<a-empty v-else :description="isRunning ? '任务进行中,当前段落结果尚未生成' : '当前没有可预览的段落结果'" />
</a-card>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate'
interface ContentBlock {
type: string
text?: string
title?: string
headers?: string[]
rows?: string[][]
}
interface LogItem {
paragraph_id: number
title: string
sort_index: number
status: string
content: { content: ContentBlock[] }
}
const route = useRoute()
const docStore = useDocumentStore()
const documentInfo = ref<any>({})
const logs = ref<LogItem[]>([])
const progressPercent = ref(0)
const progressMessage = ref('')
const selectedParagraphId = ref<number | null>(null)
let progressSource: EventSource | null = null
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
const isCompleted = computed(() => documentInfo.value.status === 'completed')
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
const logStatusMap = computed(() =>
Object.fromEntries(logs.value.map((item) => [item.paragraph_id, item.status]))
)
const selectedParagraph = computed(() =>
logs.value.find((item) => item.paragraph_id === selectedParagraphId.value) || null
)
const selectedMapping = computed(() =>
paragraphMappings.value.find((item: any) => item.paragraph_id === selectedParagraphId.value) || null
)
function statusText(status: string) {
const map: Record<string, string> = {
completed: '已完成',
failed: '失败',
cancelled: '已取消',
generating: '生成中',
pending: '等待中',
success: '成功',
}
return map[status] || status || '未知'
}
function statusBadge(status: string) {
if (status === 'completed' || status === 'success') return 'success'
if (status === 'failed') return 'error'
if (status === 'cancelled') return 'warning'
return 'processing'
}
function renderTable(block: ContentBlock) {
const headers = block.headers || []
const rows = block.rows || []
const thead = headers.length
? `<thead><tr>${headers.map((header) => `<th>${header}</th>`).join('')}</tr></thead>`
: ''
const tbody = `<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody>`
return `<table class="result-table">${thead}${tbody}</table>`
}
function renderBlocks(blocks: ContentBlock[]) {
return blocks
.map((block) => {
if (block.type === 'table') return renderTable(block)
return `<p class="result-text">${block.text || ''}</p>`
})
.join('')
}
async function loadDocument() {
const id = Number(route.params.id)
const response: any = await generateApi.getDocument(id)
documentInfo.value = response.data || {}
logs.value = response.data?.logs || []
if (!selectedParagraphId.value) {
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
}
if (
selectedParagraphId.value &&
!paragraphMappings.value.some((item: any) => item.paragraph_id === selectedParagraphId.value) &&
!logs.value.some((item) => item.paragraph_id === selectedParagraphId.value)
) {
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
}
}
function selectParagraph(paragraphId: number) {
selectedParagraphId.value = paragraphId
}
function bindProgress() {
const id = Number(route.params.id)
if (progressSource) progressSource.close()
progressSource = new EventSource(generateApi.progress(id))
progressSource.addEventListener('progress', async (event: MessageEvent) => {
const payload = JSON.parse(event.data)
progressPercent.value = payload.percent || 0
progressMessage.value = payload.message || ''
await loadDocument()
if (['completed', 'failed', 'cancelled'].includes(payload.status)) {
progressSource?.close()
progressSource = null
}
})
progressSource.onerror = () => {
progressSource?.close()
progressSource = null
}
}
async function cancelTask() {
const id = Number(route.params.id)
await docStore.cancel(id)
message.info('已发起取消请求')
}
function exportDocx() {
window.open(generateApi.exportDocx(Number(route.params.id)))
}
function exportPdf() {
window.open(generateApi.exportPdf(Number(route.params.id)))
}
onMounted(async () => {
try {
await loadDocument()
progressPercent.value = Math.floor(((documentInfo.value.para_count_done || 0) / Math.max(documentInfo.value.para_count_total || 1, 1)) * 100)
if (isRunning.value) bindProgress()
} catch (error: any) {
message.error(error.message || '加载任务详情失败')
}
})
onBeforeUnmount(() => {
progressSource?.close()
progressSource = null
})
</script>
<style scoped>
.detail-page {
padding: 24px;
height: calc(100vh - 52px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.detail-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.detail-title {
font-size: 24px;
font-weight: 700;
color: #111827;
}
.detail-desc {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
}
.detail-actions {
display: flex;
gap: 8px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.stat-card {
padding: 18px;
border-radius: 16px;
border: 1px solid #e5e7eb;
background: linear-gradient(135deg, #ffffff 0%, #f7f8fc 100%);
}
.stat-label {
font-size: 13px;
color: #6b7280;
}
.stat-value {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
color: #111827;
}
.status-card,
.preview-card {
margin-bottom: 16px;
border-radius: 18px;
flex-shrink: 0;
}
.detail-layout {
display: flex;
gap: 16px;
align-items: flex-start;
min-height: 0;
flex: 1;
overflow: hidden;
}
.detail-left {
width: 360px;
flex-shrink: 0;
height: 100%;
min-height: 0;
}
.detail-right {
min-width: 0;
flex: 1;
height: 100%;
min-height: 0;
}
.left-card {
border-radius: 18px;
height: 100%;
}
.status-message {
margin-top: 12px;
font-size: 13px;
color: #6b7280;
}
.mapping-list {
display: grid;
gap: 12px;
}
.mapping-item {
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #fafbfc;
cursor: pointer;
transition: all 0.15s ease;
}
.mapping-item:hover {
border-color: #c7d2fe;
background: #f8faff;
}
.mapping-item.active {
border-color: #4f46e5;
background: #eef2ff;
}
.mapping-top {
display: flex;
gap: 10px;
}
.mapping-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #eef2ff;
color: #4f46e5;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.mapping-main {
flex: 1;
}
.mapping-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.mapping-note {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.mapping-status {
margin-top: 10px;
}
.mapping-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mapping-file {
padding: 4px 10px;
border-radius: 999px;
background: #eefbf2;
border: 1px solid #cdebd8;
color: #1a8c4a;
font-size: 12px;
}
.mapping-empty {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
.preview-section {
padding: 16px;
border-radius: 14px;
background: #fff;
border: 1px solid #e5e7eb;
min-height: 100%;
}
.preview-section-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.preview-section-head h3 {
margin: 0;
}
.preview-note {
margin-bottom: 12px;
font-size: 13px;
color: #6b7280;
}
.preview-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
:deep(.left-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.preview-card) {
height: 100%;
}
:deep(.preview-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.result-text) {
margin: 0 0 12px;
line-height: 1.8;
}
:deep(.result-table) {
width: 100%;
border-collapse: collapse;
}
:deep(.result-table th),
:deep(.result-table td) {
border: 1px solid #d1d5db;
padding: 6px 8px;
font-size: 12px;
}
</style>
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
<template>
<div class="page-wrap">
<div class="breadcrumb">
<span>模板管理</span>
<span>/</span>
<span>所有模板</span>
</div>
<div class="page-head">
<span class="page-subtitle"> {{ templates.length }} 个模板</span>
<a-button type="primary" @click="handleUpload">
<template #icon><file-add-outlined /></template>
新建模板
</a-button>
</div>
<div class="template-grid">
<div
v-for="item in templates"
:key="item.id"
class="tpl-card"
@click="goEdit(item.id)"
>
<div class="tpl-name">
<file-text-outlined class="tpl-icon" />
{{ item.name }}
<span :class="['tpl-status', item.status === 'ready' ? 'ready' : 'editing']">
{{ item.status === 'ready' ? '已编辑' : '编辑中' }}
</span>
</div>
<div class="tpl-desc">
{{ item.description || `${item.paragraph_count || 0} 个段落,可继续编辑配置。` }}
</div>
<div class="tpl-meta">
<span>段落{{ item.paragraph_count || 0 }}</span>
<span>状态{{ item.status }}</span>
</div>
<div class="tpl-actions" @click.stop>
<a-button size="small" @click="goEdit(item.id)">编辑模板</a-button>
<a-button size="small" type="primary" @click="goGenerate(item.id)">前去生成</a-button>
<a-popconfirm title="确认删除这个模板吗?" @confirm="removeTemplate(item.id)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</div>
</div>
<div class="tpl-card tpl-card-create" @click="handleUpload">
<div class="tpl-name">
<plus-square-outlined class="tpl-icon muted" />
新建模板
<span class="tpl-status empty">未开始</span>
</div>
<div class="tpl-desc">点击创建新模板上传 Word 文件配置段落</div>
<div class="tpl-meta">
<span>段落0</span>
</div>
<div class="tpl-actions">
<a-button size="small" type="primary">新建模板</a-button>
</div>
</div>
</div>
<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 { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { FileAddOutlined, FileTextOutlined, PlusSquareOutlined } from '@ant-design/icons-vue'
import { useTemplateStore } from '@/stores/template'
const router = useRouter()
const store = useTemplateStore()
const templates = ref<any[]>([])
const uploadOpen = ref(false)
const uploadFile = ref<File | null>(null)
async function refreshList() {
await store.fetchList()
templates.value = store.templates as any
}
onMounted(async () => {
await refreshList()
})
function handleUpload() {
uploadOpen.value = true
}
function beforeUpload(file: File) {
uploadFile.value = file
return false
}
function goEdit(id: number) {
router.push(`/templates/${id}/edit`)
}
function goGenerate(id: number) {
router.push(`/generate?templateId=${id}`)
}
async function removeTemplate(id: number) {
await store.remove(id)
await refreshList()
message.success('模板已删除')
}
async function doUpload() {
if (!uploadFile.value) return
await store.upload(uploadFile.value)
uploadOpen.value = false
router.push(`/templates/${store.currentTemplate?.id}/edit`)
}
</script>
<style scoped>
.page-wrap {
padding: 24px 32px 32px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9aa1ad;
margin-bottom: 16px;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-subtitle {
font-size: 13px;
color: #5b626e;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.tpl-card {
border: 1px solid #eaecef;
border-radius: 12px;
background: #fff;
padding: 24px;
cursor: pointer;
transition: all 0.12s;
}
.tpl-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #7c7cdb;
}
.tpl-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.tpl-icon {
color: #5b5bd6;
}
.tpl-icon.muted {
color: #9aa1ad;
}
.tpl-desc {
font-size: 12px;
color: #9aa1ad;
margin-bottom: 12px;
min-height: 36px;
}
.tpl-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #9aa1ad;
}
.tpl-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
.tpl-status {
font-size: 10px;
padding: 2px 10px;
border-radius: 10px;
margin-left: auto;
}
.tpl-status.ready {
background: #e8f5e9;
color: #1a8c4a;
}
.tpl-status.editing {
background: #fff3e0;
color: #d48a00;
}
.tpl-status.empty {
background: #f0f1f3;
color: #9aa1ad;
}
.tpl-card-create {
border-style: dashed;
}
</style>
+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 }
}
}
})