updates
This commit is contained in:
commit
6f019610f8
|
|
@ -0,0 +1,325 @@
|
|||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.idea
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# ---> Vue
|
||||
# gitignore template for Vue.js projects
|
||||
#
|
||||
# Recommended template: Node.gitignore
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
docs/_book
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
test/
|
||||
|
||||
|
|
@ -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 | null(null 表示使用系统默认模型)
|
||||
- `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`,不然中文会乱码
|
||||
|
|
@ -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.0(asyncmy 驱动) | 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/ Axios(template, model, generate)
|
||||
│ ├── stores/ Pinia(template, model, document)
|
||||
│ └── router/ 6 条路由
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── models/ ORM(template, paragraph, ai_model, document, generation_log)
|
||||
│ │ ├── routers/ API(templates, 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
|
||||
- 样式完全保留(字体/颜色/行距/页边距/页眉页脚)
|
||||
|
|
@ -0,0 +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.0(asyncmy 驱动)
|
||||
- **对象存储**: 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 导出)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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})
|
||||
|
|
@ -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", []),
|
||||
}
|
||||
)
|
||||
|
|
@ -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})
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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:]}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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. 执行前端类型检查,确认本轮布局调整稳定。
|
||||
- **执行结果**: 当前任务详情页已改为“上 + 左右”结构,用户可在左侧切换段落,在右侧查看对应生成结果预览。
|
||||
|
|
@ -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。
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
# 任务执行摘要
|
||||
|
||||
## 会话 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 标题区块顺序;这比之前仅替换原位置内容更接近“模板编辑后导出顺序真实变化”的目标。
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>AI 文档模板生成系统 · 原型 v3</title>
|
||||
<style>
|
||||
:root{--c-primary:#5b5bd6;--c-bg:#f5f6f8;--c-text:#1a1d24;--c-text-2:#5b626e;--font:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||
body{font-family:var(--font);background:var(--c-bg);color:var(--c-text);display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;padding:20px}
|
||||
.container{max-width:640px;width:100%;text-align:center}
|
||||
h1{font-size:24px;margin-bottom:8px}
|
||||
p{color:var(--c-text-2);font-size:14px;margin-bottom:32px;line-height:1.6}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;text-align:left}
|
||||
.card{background:#fff;border-radius:10px;padding:20px;text-decoration:none;color:var(--c-text);border:1px solid #e0e2e6;transition:all .12s;display:block}
|
||||
.card:hover{border-color:var(--c-primary);box-shadow:0 4px 12px rgba(91,91,214,.12)}
|
||||
.card .icon{width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;margin-bottom:10px;font-size:18px}
|
||||
.card .name{font-size:15px;font-weight:600;margin-bottom:4px}
|
||||
.card .desc{font-size:12px;color:#9aa1ad;line-height:1.5}
|
||||
.footer{margin-top:32px;font-size:12px;color:#9aa1ad}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>AI 文档模板生成系统</h1>
|
||||
<p>原型 v3 · 共 5 个独立页面,点击进入各功能模块</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="模板管理.html">
|
||||
<div class="icon" style="background:#eeeefb;color:#5b5bd6">📄</div>
|
||||
<div class="name">模板管理</div>
|
||||
<div class="desc">创建、编辑模板 · 上传 Word 解析段落 · 三栏编辑模式</div>
|
||||
</a>
|
||||
<a class="card" href="模型管理.html">
|
||||
<div class="icon" style="background:#e8f5e9;color:#2e7d32">⚙</div>
|
||||
<div class="name">模型管理</div>
|
||||
<div class="desc">配置 AI 模型 · 添加/编辑 API · 设置默认生成模型</div>
|
||||
</a>
|
||||
<a class="card" href="执行生成.html">
|
||||
<div class="icon" style="background:#fff3e0;color:#e65100">⚡</div>
|
||||
<div class="name">执行生成</div>
|
||||
<div class="desc">选择模板 · 上传参考文件 · AI 自动生成 · 实时进度</div>
|
||||
</a>
|
||||
<a class="card" href="生成记录.html">
|
||||
<div class="icon" style="background:#f3e5f5;color:#7b1fa2">📋</div>
|
||||
<div class="name">生成记录</div>
|
||||
<div class="desc">查看已生成的文件 · 按模板/状态筛选 · 预览或下载</div>
|
||||
</a>
|
||||
<a class="card" href="预览编辑.html" style="grid-column:1/-1;text-align:center">
|
||||
<div class="icon" style="background:#e3f2fd;color:#1565c0;margin:0 auto 10px">👁</div>
|
||||
<div class="name">预览编辑</div>
|
||||
<div class="desc">在线查看生成结果 · 富文本编辑器 · 手动编辑 · 导出 Word/PDF</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer">操作人:周文涛 · 2026-07-02</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,727 @@
|
|||
<!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>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
:root{
|
||||
--c-primary:#5b5bd6; --c-primary-hover:#4a4ac0; --c-primary-soft:#eeeefb;
|
||||
--c-primary-2:#7c7cdb; --c-primary-dark:#3b3ba8;
|
||||
--c-bg:#f5f6f8; --c-bg-card:#ffffff; --c-bg-soft:#f0f1f3; --c-bg-hover:#eaecef;
|
||||
--c-border:#e0e2e6; --c-border-light:#eaecef;
|
||||
--c-text:#1a1d24; --c-text-2:#5b626e; --c-text-3:#9aa1ad; --c-text-4:#c4c8cf;
|
||||
--c-success:#1a8c4a; --c-warning:#d48a00; --c-danger:#d33; --c-info:#4a90d9;
|
||||
--c-orange:#e68a00;
|
||||
--font:-apple-system,"PingFang SC","Microsoft YaHei","Segoe UI",Roboto,sans-serif;
|
||||
--fs-h1:22px; --fs-h2:17px; --fs-h3:15px; --fs-body:14px; --fs-sm:13px; --fs-xs:12px;
|
||||
--fw-bold:600; --fw-medium:500;
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
|
||||
--radius:8px; --radius-sm:6px; --radius-lg:12px;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-md:0 4px 12px rgba(0,0,0,.08);
|
||||
--shadow-lg:0 8px 24px rgba(0,0,0,.10);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:var(--font);font-size:var(--fs-body);color:var(--c-text);background:var(--c-bg);line-height:1.6;-webkit-font-smoothing:antialiased;height:100vh;overflow:hidden}
|
||||
a{color:var(--c-primary);text-decoration:none}
|
||||
[data-lucide]{width:16px;height:16px;stroke-width:1.8;vertical-align:middle;color:inherit;flex-shrink:0}
|
||||
.icon-sm{width:14px;height:14px}
|
||||
.icon-lg{width:20px;height:20px}
|
||||
/* === top bar === */
|
||||
.app{display:flex;flex-direction:column;height:100vh}
|
||||
.topbar{height:52px;background:var(--c-bg-card);border-bottom:1px solid var(--c-border);display:flex;align-items:center;padding:0 var(--sp-5);flex-shrink:0;gap:var(--sp-3)}
|
||||
.topbar-logo{display:flex;align-items:center;gap:var(--sp-2);font-weight:var(--fw-bold);font-size:var(--fs-h3);color:var(--c-text);margin-right:var(--sp-4);flex-shrink:0}
|
||||
.logo-icon{width:28px;height:28px;background:var(--c-primary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700}
|
||||
.topbar-nav{display:flex;align-items:center;gap:var(--sp-1)}
|
||||
.topbar-tab{padding:var(--sp-2) var(--sp-4);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);border-radius:var(--radius-sm);transition:all .12s;user-select:none;display:flex;align-items:center;gap:var(--sp-2);white-space:nowrap}
|
||||
.topbar-tab:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.topbar-tab.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:var(--sp-3)}
|
||||
.user-badge{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);background:var(--c-bg-soft);border-radius:20px;cursor:pointer}
|
||||
.user-avatar-sm{width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold)}
|
||||
/* === main layout === */
|
||||
.main-area{flex:1;overflow:hidden;display:flex}
|
||||
.screen{display:none;flex:1;overflow:hidden}
|
||||
.screen.active{display:flex}
|
||||
.scroll-area{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6)}
|
||||
/* === breadcrumb === */
|
||||
.breadcrumb{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-sm);color:var(--c-text-3);margin-bottom:var(--sp-4);flex-wrap:wrap}
|
||||
.breadcrumb span,.breadcrumb a{color:var(--c-text-2)}
|
||||
/* === card === */
|
||||
.card{background:var(--c-bg-card);border-radius:var(--radius-lg);border:1px solid var(--c-border);overflow:hidden}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.card-title{font-size:var(--fs-h3);font-weight:var(--fw-bold);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.card-body{padding:var(--sp-5)}
|
||||
/* === buttons === */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 18px;border-radius:var(--radius);font-size:var(--fs-sm);font-weight:var(--fw-medium);cursor:pointer;border:1px solid transparent;transition:all .12s;line-height:1.4;white-space:nowrap}
|
||||
.btn-primary{background:var(--c-primary);color:#fff;border-color:var(--c-primary)}
|
||||
.btn-primary:hover{background:var(--c-primary-hover)}
|
||||
.btn-outline{background:transparent;color:var(--c-text);border-color:var(--c-border)}
|
||||
.btn-outline:hover{background:var(--c-bg-soft);border-color:var(--c-text-3)}
|
||||
.btn-ghost{background:transparent;color:var(--c-text-2);border-color:transparent;padding:6px 10px}
|
||||
.btn-ghost:hover{background:var(--c-bg-soft)}
|
||||
.btn-success{background:var(--c-success);color:#fff}
|
||||
.btn-success:hover{background:#157a3e}
|
||||
.btn-sm{padding:5px 12px;font-size:var(--fs-xs)}
|
||||
.btn-xs{padding:3px 8px;font-size:11px;border-radius:var(--radius-sm)}
|
||||
/* === form === */
|
||||
.form-group{margin-bottom:var(--sp-4)}
|
||||
.form-label{display:block;font-size:var(--fs-sm);font-weight:var(--fw-medium);color:var(--c-text-2);margin-bottom:var(--sp-1)}
|
||||
.form-input,.form-select{width:100%;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;transition:border-color .15s}
|
||||
.form-input:focus,.form-select:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
.form-textarea{width:100%;min-height:80px;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);resize:vertical;outline:none}
|
||||
.form-textarea:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
/* === template list === */
|
||||
.template-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--sp-4)}
|
||||
.tpl-card{border:1px solid var(--c-border-light);border-radius:var(--radius-lg);background:var(--c-bg-card);padding:var(--sp-5);cursor:pointer;transition:all .12s}
|
||||
.tpl-card:hover{box-shadow:var(--shadow-md);border-color:var(--c-primary-2)}
|
||||
.tpl-card .tpl-name{font-size:var(--fs-body);font-weight:var(--fw-bold);margin-bottom:var(--sp-1);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.tpl-card .tpl-desc{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-3)}
|
||||
.tpl-card .tpl-meta{display:flex;align-items:center;gap:var(--sp-3);font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.tpl-card .tpl-actions{margin-top:var(--sp-3);display:flex;gap:var(--sp-2)}
|
||||
.tpl-status{font-size:10px;padding:2px 10px;border-radius:10px;display:inline-block}
|
||||
.tpl-status.ready{background:#e8f5e9;color:var(--c-success)}
|
||||
.tpl-status.editing{background:#fff3e0;color:var(--c-warning)}
|
||||
/.tpl-status.empty{background:var(--c-bg-soft);color:var(--c-text-3)}
|
||||
/* === template editor LAYOUT (left+right split) === */
|
||||
.tpl-editor{display:flex;flex:1;overflow:hidden}
|
||||
.tpl-left{width:380px;flex-shrink:0;border-right:1px solid var(--c-border);background:var(--c-bg-card);display:flex;flex-direction:column;overflow:hidden}
|
||||
.tpl-left-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.tpl-left-scroll{flex:1;overflow-y:auto;padding:var(--sp-4)}
|
||||
.tpl-right{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--c-bg)}
|
||||
.tpl-right-toolbar{display:flex;align-items:center;padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--c-border);background:var(--c-bg-card);flex-shrink:0;gap:var(--sp-2)}
|
||||
.tpl-right-body{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6);display:flex;justify-content:center}
|
||||
/* === mode toggle === */
|
||||
.mode-group{display:flex;border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;flex-shrink:0}
|
||||
.mode-btn{padding:4px 14px;cursor:pointer;font-size:var(--fs-xs);color:var(--c-text-2);background:var(--c-bg-card);border:none;border-right:1px solid var(--c-border);transition:all .12s}
|
||||
.mode-btn:last-child{border-right:none}
|
||||
.mode-btn.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.mode-btn:hover:not(.active){background:var(--c-bg-soft)}
|
||||
/* === paragraph markers === */
|
||||
.doc-edit-page{width:794px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:80px 72px 120px;min-height:500px;height:auto;font-size:14px;line-height:1.8;position:relative}
|
||||
.doc-edit-page .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.doc-edit-page .para-block{position:relative;padding:var(--sp-2);margin:var(--sp-1) 0;border-radius:var(--radius-sm);border:2px solid transparent;cursor:pointer;transition:all .12s}
|
||||
.doc-edit-page .para-block:hover{border-color:var(--c-border)}
|
||||
.doc-edit-page .para-block.selected{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.doc-edit-page .para-block .para-tag{position:absolute;right:4px;top:4px;font-size:9px;padding:1px 6px;border-radius:4px;display:none}
|
||||
.doc-edit-page .para-block:hover .para-tag,.doc-edit-page .para-block.selected .para-tag{display:block}
|
||||
.doc-edit-page .para-block .para-tag.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.doc-edit-page .para-block .para-tag.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
.doc-edit-page table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.doc-edit-page th,.doc-edit-page td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.doc-edit-page th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === edit mode rich editor === */
|
||||
.edit-toolbar{display:flex;align-items:center;gap:var(--sp-1);padding:var(--sp-2) var(--sp-3);border:1px solid var(--c-border);border-bottom:none;border-radius:var(--radius) var(--radius) 0 0;background:var(--c-bg-card);flex-wrap:wrap;flex-shrink:0}
|
||||
.edit-toolbar .tb-divider{width:1px;height:18px;background:var(--c-border);margin:0 var(--sp-1)}
|
||||
.edit-toolbar .tb-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);cursor:pointer;color:var(--c-text-2);font-size:var(--fs-sm)}
|
||||
.edit-toolbar .tb-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.edit-toolbar .tb-btn.active{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.edit-body{border:1px solid var(--c-border);border-radius:0 0 var(--radius) var(--radius);padding:var(--sp-5) var(--sp-6);background:var(--c-bg-card);min-height:500px;outline:none;font-size:14px;line-height:1.8;color:var(--c-text);width:794px}
|
||||
.edit-body:focus{border-color:var(--c-primary)}
|
||||
.edit-body .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.edit-body .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.edit-body .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.edit-body table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.edit-body th,.edit-body td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.edit-body th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === left panel config === */
|
||||
.config-section{margin-bottom:var(--sp-5)}
|
||||
.config-section-title{font-size:var(--fs-sm);font-weight:var(--fw-bold);color:var(--c-text);margin-bottom:var(--sp-3);padding-bottom:var(--sp-2);border-bottom:1px solid var(--c-border-light)}
|
||||
.config-field{margin-bottom:var(--sp-3)}
|
||||
.config-field .cf-label{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-1)}
|
||||
.config-field .cf-value{font-size:var(--fs-sm);color:var(--c-text)}
|
||||
.cfg-toggle{display:flex;align-items:center;gap:var(--sp-2);cursor:pointer;user-select:none;padding:var(--sp-1) 0}
|
||||
.toggle-switch{width:32px;height:18px;border-radius:9px;background:var(--c-border);position:relative;cursor:pointer;transition:background .2s;flex-shrink:0}
|
||||
.toggle-switch.on{background:var(--c-primary)}
|
||||
.toggle-switch::after{content:'';width:14px;height:14px;border-radius:50%;background:#fff;position:absolute;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 2px rgba(0,0,0,.15)}
|
||||
.toggle-switch.on::after{transform:translateX(14px)}
|
||||
.cfg-select{padding:6px 10px;border:1px solid var(--c-border);border-radius:var(--radius-sm);font-size:var(--fs-sm);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;width:100%}
|
||||
.cfg-select:focus{border-color:var(--c-primary)}
|
||||
/* === left panel paragraph list (edit mode) === */
|
||||
.para-list-item{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);transition:all .12s;margin-bottom:2px}
|
||||
.para-list-item:hover{background:var(--c-bg-soft)}
|
||||
.para-list-item.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.para-list-item .pli-index{width:20px;height:20px;border-radius:50%;background:var(--c-bg-soft);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold);color:var(--c-text-3);flex-shrink:0}
|
||||
.para-list-item.active .pli-index{background:var(--c-primary);color:#fff}
|
||||
.para-list-item .pli-badge{font-size:9px;padding:0 6px;border-radius:6px;margin-left:auto;flex-shrink:0}
|
||||
.para-list-item .pli-badge.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.para-list-item .pli-badge.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
/* === gen progress & hints === */
|
||||
.gen-progress{background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.spinner{width:20px;height:20px;border:2px solid var(--c-border);border-top-color:var(--c-primary);border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
/* === upload zone === */
|
||||
.upload-zone{border:2px dashed var(--c-border);border-radius:var(--radius-lg);padding:var(--sp-6) var(--sp-4);text-align:center;cursor:pointer;transition:all .15s;background:var(--c-bg-card)}
|
||||
.upload-zone:hover{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.upload-icon{width:48px;height:48px;background:var(--c-bg-soft);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto var(--sp-3);color:var(--c-text-3)}
|
||||
.uploaded-file{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--c-bg-soft);border-radius:var(--radius);margin-top:var(--sp-3)}
|
||||
.uploaded-file .file-icon.excel{background:#e8f5e9;color:#2e7d32}
|
||||
/* === model card === */
|
||||
.model-card{border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-3);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.model-card .mc-icon{width:40px;height:40px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:16px;font-weight:var(--fw-bold);color:#fff}
|
||||
.model-card .mc-info{flex:1;min-width:0}
|
||||
.model-card .mc-name{font-size:var(--fs-sm);font-weight:var(--fw-medium)}
|
||||
.model-card .mc-provider{font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.model-card .mc-status{font-size:var(--fs-xs)}
|
||||
.model-card .mc-status.on{color:var(--c-success)}
|
||||
.model-card .mc-status.off{color:var(--c-text-3)}
|
||||
/* === test result modal === */
|
||||
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:1000;align-items:center;justify-content:center}
|
||||
.modal-overlay.open{display:flex}
|
||||
.modal{background:var(--c-bg-card);border-radius:var(--radius-lg);width:640px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
|
||||
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border);font-size:var(--fs-h3);font-weight:var(--fw-bold)}
|
||||
.modal-header .close-btn{cursor:pointer;color:var(--c-text-3);padding:4px;border-radius:var(--radius-sm)}
|
||||
.modal-header .close-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.modal-body{padding:var(--sp-5)}
|
||||
.modal-footer{display:flex;justify-content:flex-end;gap:var(--sp-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--c-border-light)}
|
||||
/* === typography === */
|
||||
.text-xs{font-size:var(--fs-xs)}
|
||||
.text-sm{font-size:var(--fs-sm)}
|
||||
.text-muted{color:var(--c-text-3)}
|
||||
.flex{display:flex}
|
||||
.flex-1{flex:1}
|
||||
.gap-2{gap:var(--sp-2)}
|
||||
.gap-3{gap:var(--sp-3)}
|
||||
.items-center{align-items:center}
|
||||
.justify-between{justify-content:space-between}
|
||||
.mb-3{margin-bottom:var(--sp-3)}
|
||||
.mb-4{margin-bottom:var(--sp-4)}
|
||||
.mt-3{margin-top:var(--sp-3)}
|
||||
.p-3{padding:var(--sp-3)}
|
||||
.p-4{padding:var(--sp-4)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="topbar-logo"><div class="logo-icon">A</div><span>AI 文档模板</span></div>
|
||||
<a class="topbar-tab" href="模板管理.html"><i data-lucide="layout-dashboard" class="icon-sm"></i> 模板管理</a>
|
||||
<a class="topbar-tab" href="模型管理.html"><i data-lucide="cpu" class="icon-sm"></i> 模型管理</a>
|
||||
<a class="topbar-tab active" href="执行生成.html"><i data-lucide="zap" class="icon-sm"></i> 执行生成</a>
|
||||
<a class="topbar-tab" href="生成记录.html"><i data-lucide="clock" class="icon-sm"></i> 生成记录</a>
|
||||
<div class="topbar-right">
|
||||
<span class="text-xs text-muted">v3.0</span>
|
||||
<div class="user-badge"><span class="text-xs" style="font-weight:var(--fw-medium)">周文涛</span><div class="user-avatar-sm">周</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="screen active" id="screen-generate">
|
||||
<div class="scroll-area" style="display:flex;flex-direction:column;height:100%;padding-bottom:0">
|
||||
<div class="breadcrumb"><i data-lucide="home" class="icon-sm"></i> 执行生成 / <span>选择模板 → 上传文件 → AI 自动生成</span></div>
|
||||
<div style="display:flex;gap:var(--sp-5);flex:1;min-height:0">
|
||||
<!-- Left: 选择模板 -->
|
||||
<div style="width:340px;flex-shrink:0;display:flex;flex-direction:column">
|
||||
<div class="card" style="flex-shrink:0">
|
||||
<div class="card-header"><span class="card-title"><i data-lucide="file-text" style="color:var(--c-primary)"></i>选择模板</span></div>
|
||||
<div class="card-body">
|
||||
<div class="form-group" style="margin-bottom:var(--sp-3)">
|
||||
<select class="form-select" onchange="onTplSelect()">
|
||||
<option value="" disabled selected>— 请选择已编辑好的模板 —</option>
|
||||
<option value="季度经济活动分析报告" selected>季度经济活动分析报告</option>
|
||||
<option value="年度工作总结模板">年度工作总结模板</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-soft);border-radius:var(--radius);padding:var(--sp-3);font-size:var(--fs-sm);color:var(--c-text-2)">
|
||||
<div style="font-weight:var(--fw-medium);margin-bottom:var(--sp-1)">季度经济活动分析报告</div>
|
||||
<div class="text-xs text-muted">8 个段落 · 3 个需上传参考文件 · 5 个可直接生成</div>
|
||||
</div>
|
||||
<!-- 模板统计 -->
|
||||
<div style="display:flex;gap:var(--sp-3);margin-top:var(--sp-3);font-size:var(--fs-sm)">
|
||||
<div style="flex:1;text-align:center;padding:var(--sp-2);background:var(--c-bg-soft);border-radius:var(--radius-sm)">
|
||||
<div style="font-size:18px;font-weight:var(--fw-bold);color:var(--c-primary)">8</div>
|
||||
<div class="text-xs text-muted">总段落</div>
|
||||
</div>
|
||||
<div style="flex:1;text-align:center;padding:var(--sp-2);background:#e8f5e9;border-radius:var(--radius-sm)">
|
||||
<div style="font-size:18px;font-weight:var(--fw-bold);color:var(--c-success)">5</div>
|
||||
<div class="text-xs text-muted">自动生成</div>
|
||||
</div>
|
||||
<div style="flex:1;text-align:center;padding:var(--sp-2);background:#fff3e0;border-radius:var(--radius-sm)">
|
||||
<div style="font-size:18px;font-weight:var(--fw-bold);color:var(--c-warning)">3</div>
|
||||
<div class="text-xs text-muted">需要文件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 进度显示在左侧底部 -->
|
||||
<div id="genLeftStatus" style="display:none;flex:1;margin-top:var(--sp-4)">
|
||||
<div class="gen-progress"><div class="spinner"></div><div style="flex:1;font-size:var(--fs-sm);color:var(--c-text-2)" id="genLeftText">正在生成「一、主要经营指标完成情况」...</div></div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-3);margin-top:var(--sp-3)">
|
||||
<div style="font-size:var(--fs-sm);font-weight:var(--fw-medium);margin-bottom:var(--sp-2)">生成进度</div>
|
||||
<div style="height:8px;background:var(--c-bg-soft);border-radius:4px;overflow:hidden;margin-bottom:var(--sp-3)"><div id="genProgressBar" style="width:42%;height:100%;background:var(--c-primary);border-radius:4px;transition:width .3s"></div></div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--sp-1);font-size:var(--fs-xs)">
|
||||
<div class="flex items-center gap-2"><span id="g1" style="color:var(--c-success)"><i data-lucide="check-circle" class="icon-sm"></i></span><span style="color:var(--c-text-2)">一、主要经营指标完成情况</span></div>
|
||||
<div class="flex items-center gap-2"><span id="g2" style="color:var(--c-success)"><i data-lucide="check-circle" class="icon-sm"></i></span><span style="color:var(--c-text-2)">主要经营指标完成情况表</span></div>
|
||||
<div class="flex items-center gap-2"><span id="g3" style="color:var(--c-primary)"><i data-lucide="loader" class="icon-sm"></i></span><span style="color:var(--c-text)">二、成本费用分析</span></div>
|
||||
<div class="flex items-center gap-2"><span id="g4" style="color:var(--c-text-4)"><i data-lucide="circle" class="icon-sm"></i></span><span class="text-muted">三、存在的主要问题</span></div>
|
||||
<div class="flex items-center gap-2"><span id="g5" style="color:var(--c-text-4)"><i data-lucide="circle" class="icon-sm"></i></span><span class="text-muted">四、下一步工作措施</span></div>
|
||||
<div class="flex items-center gap-2"><span id="g6" style="color:var(--c-text-4)"><i data-lucide="circle" class="icon-sm"></i></span><span class="text-muted">附图:各季度营收趋势图</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 完成状态 -->
|
||||
<div id="genDoneSide" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:var(--sp-3);margin-top:var(--sp-4);align-items:center;gap:var(--sp-3)">
|
||||
<i data-lucide="check-circle" style="color:var(--c-success);flex-shrink:0"></i>
|
||||
<span style="flex:1;font-size:var(--fs-sm)">全部生成完毕</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: 段落 + 上传 -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;min-width:0">
|
||||
<div class="card" style="flex:1;display:flex;flex-direction:column">
|
||||
<div class="card-header"><span class="card-title"><i data-lucide="list" style="color:var(--c-primary)"></i>段落文件配置</span><span class="text-xs text-muted">根据模板配置,以下段落需要上传参考文件</span></div>
|
||||
<div style="flex:1;overflow-y:auto;padding:var(--sp-4)">
|
||||
<!-- 段落1 -->
|
||||
<div style="border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-3);margin-bottom:var(--sp-3);background:var(--c-bg-card)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700">1</span>
|
||||
<span style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">一、主要经营指标完成情况</span>
|
||||
<span style="font-size:10px;padding:1px 8px;border-radius:6px;background:var(--c-primary-soft);color:var(--c-primary)">Claude 3.5</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--sp-2)">
|
||||
<span class="text-xs" style="color:var(--c-success)"><i data-lucide="check-circle" class="icon-sm"></i>已上传</span>
|
||||
<button class="btn btn-ghost btn-xs">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1);padding-left:var(--sp-8)">备注:需上传财务部提供的季度财务数据报表(Excel格式,含各指标完成值)</div>
|
||||
</div>
|
||||
<!-- 段落2 -->
|
||||
<div style="border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-3);margin-bottom:var(--sp-3);background:var(--c-bg-card)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700">2</span>
|
||||
<span style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">主要经营指标完成情况表</span>
|
||||
<span style="font-size:10px;padding:1px 8px;border-radius:6px;background:#e8f5e9;color:var(--c-success)">GPT-4o</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--sp-2)">
|
||||
<span class="text-xs" style="color:var(--c-success)"><i data-lucide="check-circle" class="icon-sm"></i>已上传</span>
|
||||
<button class="btn btn-ghost btn-xs">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1);padding-left:var(--sp-8)">备注:需上传各部门经营数据汇总表(Excel格式,含指标明细)</div>
|
||||
</div>
|
||||
<!-- 段落3 -->
|
||||
<div style="border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-3);margin-bottom:var(--sp-3);background:var(--c-bg-card)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700">3</span>
|
||||
<span style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">附图:各季度营收趋势图</span>
|
||||
<span style="font-size:10px;padding:1px 8px;border-radius:6px;background:#fff3e0;color:#e65100">DeepSeek-V3</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--sp-2)">
|
||||
<span class="text-xs" style="color:var(--c-success)"><i data-lucide="check-circle" class="icon-sm"></i>已上传</span>
|
||||
<button class="btn btn-ghost btn-xs">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1);padding-left:var(--sp-8)">备注:需上传营收数据季度表(Excel格式)</div>
|
||||
</div>
|
||||
<!-- 自动生成段落(不需要上传文件) -->
|
||||
<div style="border:1px dashed var(--c-border-light);border-radius:var(--radius);padding:var(--sp-3);margin-bottom:var(--sp-3);background:var(--c-bg-soft);opacity:.7">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--c-bg-hover);color:var(--c-text-3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700">4</span>
|
||||
<span style="font-size:var(--fs-sm);color:var(--c-text-2)">二、成本费用分析</span>
|
||||
<span style="font-size:10px;padding:1px 8px;border-radius:6px;background:var(--c-primary-soft);color:var(--c-primary)">Claude 3.5</span>
|
||||
<span style="margin-left:auto;font-size:var(--fs-xs);color:var(--c-text-3)">无需上传文件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border:1px dashed var(--c-border-light);border-radius:var(--radius);padding:var(--sp-3);margin-bottom:var(--sp-3);background:var(--c-bg-soft);opacity:.7">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--c-bg-hover);color:var(--c-text-3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700">5</span>
|
||||
<span style="font-size:var(--fs-sm);color:var(--c-text-2)">三、存在的主要问题</span>
|
||||
<span style="font-size:10px;padding:1px 8px;border-radius:6px;background:var(--c-primary-soft);color:var(--c-primary)">Claude 3.5</span>
|
||||
<span style="margin-left:auto;font-size:var(--fs-xs);color:var(--c-text-3)">无需上传文件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border:1px dashed var(--c-border-light);border-radius:var(--radius);padding:var(--sp-3);margin-bottom:var(--sp-3);background:var(--c-bg-soft);opacity:.7">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--c-bg-hover);color:var(--c-text-3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700">6</span>
|
||||
<span style="font-size:var(--fs-sm);color:var(--c-text-2)">四、下一步工作措施</span>
|
||||
<span style="font-size:10px;padding:1px 8px;border-radius:6px;background:var(--c-primary-soft);color:var(--c-primary)">Claude 3.5</span>
|
||||
<span style="margin-left:auto;font-size:var(--fs-xs);color:var(--c-text-3)">无需上传文件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部: 统计 + 生成按钮 -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:var(--sp-3) var(--sp-4);border-top:1px solid var(--c-border-light);background:var(--c-bg-soft);border-radius:0 0 var(--radius-lg) var(--radius-lg)">
|
||||
<span class="text-xs text-muted"><span style="color:var(--c-success);font-weight:var(--fw-medium)">3/3</span> 个需上传文件的段落已准备就绪 · <span style="color:var(--c-text-2)">3</span> 个自动生成</span>
|
||||
<button class="btn btn-primary" onclick="startGeneration()"><i data-lucide="zap"></i>立即生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// === Screen switch ===
|
||||
function switchScreen(el, id) {
|
||||
// If called with a single string argument, treat it as screen id
|
||||
if(!id && typeof el === 'string') {
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
document.getElementById(el).classList.add('active');
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
// === Template list nav ===
|
||||
function backToTplList() {
|
||||
document.getElementById('tplList').style.display = 'flex';
|
||||
document.getElementById('tplEditor').style.display = 'none';
|
||||
}
|
||||
function editTemplate(name) {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = name;
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
function startNewTemplate() {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = '新建模板';
|
||||
alert('模拟:上传 Word 模板弹窗');
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
// === 3-column editor functions ===
|
||||
const paraData = [
|
||||
{id:1, name:'标题:2025年Q1报告', badge:'manual', label:'人工编辑'},
|
||||
{id:2, name:'一、主要经营指标完成情况', badge:'ai', label:'AI 生成'},
|
||||
{id:3, name:'主要经营指标完成情况表', badge:'ai', label:'AI 生成'},
|
||||
{id:4, name:'二、成本费用分析', badge:'ai', label:'AI 生成'},
|
||||
{id:5, name:'三、存在的主要问题', badge:'ai', label:'AI 生成'},
|
||||
{id:6, name:'四、下一步工作措施', badge:'ai', label:'AI 生成'},
|
||||
{id:7, name:'封面信息', badge:'manual', label:'人工编辑'},
|
||||
{id:8, name:'附图:各季度营收趋势图', badge:'ai', label:'AI 生成'},
|
||||
];
|
||||
|
||||
const paraConfigs = {
|
||||
1: {name:'一、主要经营指标完成情况', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'请根据上传的财务数据,生成经营指标完成情况章节,要求包括营业收入、利润总额等核心指标的完成值、同比变化及原因分析。', needPrompt:true, needFile:true, format:'正式报告段落', note:'需上传财务部提供的季度财务数据报表(Excel格式,含各指标完成值)'},
|
||||
2: {name:'主要经营指标完成情况表', mode:'AI 生成', model:'GPT-4o', prompt:'根据上传的各部门数据,生成指标完成情况表,包含指标名、完成值、同比、完成率、责任部门。', needPrompt:true, needFile:true, format:'表格形式', note:'需上传各部门经营数据汇总表(Excel格式,含指标明细)'},
|
||||
3: {name:'二、成本费用分析', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'根据财务数据生成成本费用分析内容,包括各项成本构成、同比变化及原因。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
4: {name:'三、存在的主要问题', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'基于经营数据和报告要求,分析当前存在的主要问题和风险。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
5: {name:'四、下一步工作措施', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'针对问题生成具体可执行的工作措施。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
6: {name:'封面信息', mode:'人工编辑', model:'—', prompt:'', needPrompt:false, needFile:false, format:'—', note:''},
|
||||
};
|
||||
|
||||
function initParaList() {
|
||||
let html = '';
|
||||
paraData.forEach(p => {
|
||||
html += '<div class="para-list-item" id="leftParaItem'+p.id+'" onclick="selectPara('+p.id+')">'
|
||||
+ '<span class="pli-index">'+p.id+'</span>'
|
||||
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+p.name+'</span>'
|
||||
+ '<span class="pli-badge '+p.badge+'">'+p.label+'</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
document.getElementById('leftParaList').innerHTML = html;
|
||||
}
|
||||
|
||||
function selectPara(id) {
|
||||
// Update left list
|
||||
document.querySelectorAll('.para-list-item').forEach(e => e.classList.remove('active'));
|
||||
const leftItem = document.getElementById('leftParaItem'+id);
|
||||
if(leftItem) leftItem.classList.add('active');
|
||||
// Scroll center to paragraph
|
||||
const block = document.getElementById('paraBlock'+id);
|
||||
if(block) {
|
||||
block.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
document.querySelectorAll('.para-block').forEach(b => b.classList.remove('selected'));
|
||||
block.classList.add('selected');
|
||||
}
|
||||
// Update right panel
|
||||
buildRightPanel(id);
|
||||
}
|
||||
|
||||
function selectParaFromDoc(id) {
|
||||
selectPara(id);
|
||||
}
|
||||
|
||||
function buildRightPanel(id) {
|
||||
const cfg = paraConfigs[id] || paraConfigs[1];
|
||||
const isAi = cfg.mode === 'AI 生成';
|
||||
document.getElementById('rightPanelTitle').textContent = '段落配置';
|
||||
document.getElementById('rightParaName').textContent = '第 '+id+' 段 · '+(cfg.name||'');
|
||||
const html = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">段落信息</div>
|
||||
<div class="config-field"><div class="cf-label">段落名称</div><div class="cf-value" style="font-weight:var(--fw-medium)">${cfg.name}</div></div>
|
||||
<div class="config-field"><div class="cf-label">所处位置</div><div class="cf-value text-sm text-muted">第 ${id} 段</div></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">生成配置</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">编辑方式</div>
|
||||
<select class="cfg-select" onchange="onEditModeChange(this,${id})">
|
||||
<option value="人工编辑" ${cfg.mode==='人工编辑'?'selected':''}>人工编辑</option>
|
||||
<option value="AI 生成" ${cfg.mode==='AI 生成'?'selected':''}>AI 生成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rmodelField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">生成模型</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.model==='Claude 3.5 Sonnet'?'selected':''}>Claude 3.5 Sonnet</option>
|
||||
<option ${cfg.model==='GPT-4o'?'selected':''}>GPT-4o</option>
|
||||
<option ${cfg.model==='DeepSeek-V3'?'selected':''}>DeepSeek-V3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rformatField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">输出格式</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.format==='正式报告段落'?'selected':''}>正式报告段落</option>
|
||||
<option ${cfg.format==='表格形式'?'selected':''}>表格形式</option>
|
||||
<option ${cfg.format==='分条列出'?'selected':''}>分条列出</option>
|
||||
<option ${cfg.format==='图表形式'?'selected':''}>图表形式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要提示词</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needPrompt?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needPrompt?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rpromptField${id}" style="${cfg.needPrompt?'':'display:none'}">
|
||||
<div class="cf-label">预设提示词 <span class="text-xs text-muted">(编辑模板时设置)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:80px;font-size:var(--fs-sm)">${cfg.prompt || '请在此输入提示词...'}</textarea>
|
||||
<div class="text-xs text-muted mt-3">提示词在模板编辑时预设,生成时可确认或微调</div>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要参考文件</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needFile?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needFile?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rnoteField${id}" style="${cfg.needFile?'':'display:none'}">
|
||||
<div class="cf-label">备注说明 <span class="text-xs text-muted">(执行生成时提示需上传什么文件)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:60px;font-size:var(--fs-sm)" placeholder="例如:需上传财务部提供的季度财务数据报表">${cfg.note || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
${isAi ? `
|
||||
<div id="rtestBtn${id}">
|
||||
<button class="btn btn-primary btn-block" style="width:100%;justify-content:center" onclick="testParagraph(${id})">
|
||||
<i data-lucide="zap" class="icon-sm"></i> 立即测试
|
||||
</button>
|
||||
<div class="text-xs text-muted" style="text-align:center;margin-top:var(--sp-2)">针对此段落单独测试 · 需上传附件 → 文件解析 → 请求AI → 填充预览</div>
|
||||
</div>
|
||||
` : `
|
||||
<div style="background:var(--c-bg-soft);padding:var(--sp-3);border-radius:var(--radius);text-align:center">
|
||||
<span class="text-xs text-muted">该段落为「人工编辑」,无需 AI 测试</span>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
document.getElementById('rightPanelBody').innerHTML = html;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function onEditModeChange(sel, paraId) {
|
||||
const isAi = sel.value === 'AI 生成';
|
||||
document.getElementById('rmodelField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rformatField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rpromptField'+paraId).style.display = 'block';
|
||||
const testBtn = document.getElementById('rtestBtn'+paraId);
|
||||
if(testBtn) testBtn.style.display = isAi ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// === Toggle switch ===
|
||||
function toggleSw(el) {
|
||||
const sw = el.querySelector('.toggle-switch');
|
||||
const txt = el.querySelector('.text-xs');
|
||||
sw.classList.toggle('on');
|
||||
if(txt) txt.textContent = sw.classList.contains('on') ? '是' : '否';
|
||||
// Show/hide note field (sibling config-field with noteField id)
|
||||
const noteField = el.closest('.config-field')?.nextElementSibling;
|
||||
if(noteField && noteField.id && noteField.id.startsWith('noteField')) {
|
||||
noteField.style.display = sw.classList.contains('on') ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// === Test paragraph - 3 step flow ===
|
||||
function testParagraph(id) {
|
||||
// Reset modal to step 1
|
||||
document.getElementById('testStep1').style.display = 'block';
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'none';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
document.getElementById('testUploaded').style.display = 'none';
|
||||
document.getElementById('testModal').classList.add('open');
|
||||
document.querySelector('#testStep1 .config-section-title span').style.background = 'var(--c-primary)';
|
||||
}
|
||||
|
||||
function simulateUpload() {
|
||||
document.querySelector('#testStep1 > div:first-child > div > div').style.display = 'none'; // hide upload box
|
||||
document.getElementById('testUploaded').style.display = 'block';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function startTestStep2() {
|
||||
document.getElementById('testStep1').style.display = 'none';
|
||||
document.getElementById('testStep2').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
// Reset task icons
|
||||
['tsk1','tsk2','tsk3'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.innerHTML = '<i data-lucide="circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-text-4)';
|
||||
});
|
||||
lucide.createIcons();
|
||||
|
||||
// Simulate step progression
|
||||
const t1 = document.getElementById('tsk1');
|
||||
const t2 = document.getElementById('tsk2');
|
||||
const t3 = document.getElementById('tsk3');
|
||||
const stepText = document.getElementById('testStepText');
|
||||
|
||||
// Step 1: File parsing (after 1.2s)
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在解析文件...';
|
||||
lucide.createIcons();
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-success)';
|
||||
// Step 2: AI request
|
||||
t2.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在请求 AI 大模型(Claude 3.5 Sonnet)...';
|
||||
lucide.createIcons();
|
||||
}, 1400);
|
||||
|
||||
setTimeout(() => {
|
||||
t2.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-success)';
|
||||
// Step 3: Fill result
|
||||
t3.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在填充生成结果...';
|
||||
lucide.createIcons();
|
||||
}, 3200);
|
||||
|
||||
setTimeout(() => {
|
||||
t3.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-success)';
|
||||
// Switch to step 3
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
}, 4800);
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
document.getElementById('testModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Model management modals ===
|
||||
function openModelModal(mode, name, provider, endpoint, apikey) {
|
||||
const title = document.getElementById('modelModalTitle');
|
||||
const mName = document.getElementById('mName');
|
||||
const mProvider = document.getElementById('mProvider');
|
||||
const mFormat = document.getElementById('mFormat');
|
||||
const mEndpoint = document.getElementById('mEndpoint');
|
||||
const mApiKey = document.getElementById('mApiKey');
|
||||
if(mode === 'add') {
|
||||
title.innerHTML = '<i data-lucide="cpu" class="icon-sm" style="color:var(--c-primary)"></i> 添加模型';
|
||||
mName.value = '';
|
||||
mProvider.value = '';
|
||||
mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = '';
|
||||
mApiKey.value = '';
|
||||
} else {
|
||||
title.innerHTML = '<i data-lucide="settings" class="icon-sm" style="color:var(--c-primary)"></i> 编辑模型 - ' + name;
|
||||
mName.value = name || '';
|
||||
mProvider.value = provider || '';
|
||||
// Guess format from provider name
|
||||
if(provider && provider.toLowerCase().includes('anthropic')) mFormat.value = 'Anthropic';
|
||||
else if(provider && (provider.toLowerCase().includes('openai') || provider.toLowerCase().includes('deepseek') || provider.toLowerCase().includes('阿里'))) mFormat.value = 'OpenAI';
|
||||
else mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = endpoint || '';
|
||||
mApiKey.value = apikey || '';
|
||||
}
|
||||
document.getElementById('modelModal').classList.add('open');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeModelModal() {
|
||||
document.getElementById('modelModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Generation ===
|
||||
function startGeneration() {
|
||||
document.getElementById('genLeftStatus').style.display = 'block';
|
||||
document.getElementById('genDoneSide').style.display = 'none';
|
||||
let pct = 0;
|
||||
const bar = document.getElementById('genProgressBar');
|
||||
const gpText = document.getElementById('genLeftText');
|
||||
const steps = [
|
||||
{id:'g1', name:'一、主要经营指标完成情况'},
|
||||
{id:'g2', name:'主要经营指标完成情况表'},
|
||||
{id:'g3', name:'二、成本费用分析'},
|
||||
{id:'g4', name:'三、存在的主要问题'},
|
||||
{id:'g5', name:'四、下一步工作措施'},
|
||||
{id:'g6', name:'附图:各季度营收趋势图'}
|
||||
];
|
||||
let currentStep = 0;
|
||||
const interval = setInterval(() => {
|
||||
pct += Math.floor(Math.random()*8)+3;
|
||||
if(pct>=100) {
|
||||
pct=100;
|
||||
clearInterval(interval);
|
||||
document.getElementById('genLeftStatus').style.display = 'none';
|
||||
document.getElementById('genDoneSide').style.display = 'flex';
|
||||
// Mark all remaining as done
|
||||
for(let i=currentStep;i<steps.length;i++) {
|
||||
const el = document.getElementById(steps[i].id);
|
||||
el.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-success)';
|
||||
}
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
bar.style.width = pct+'%';
|
||||
// Update step progress
|
||||
const stepIdx = Math.floor(pct / 17);
|
||||
if(stepIdx > currentStep && stepIdx < steps.length) {
|
||||
currentStep = stepIdx;
|
||||
// Mark previous as done
|
||||
if(currentStep > 0) {
|
||||
const prev = document.getElementById(steps[currentStep-1].id);
|
||||
if(prev) { prev.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>'; prev.style.color = 'var(--c-success)'; }
|
||||
}
|
||||
// Mark current as in progress
|
||||
const cur = document.getElementById(steps[currentStep].id);
|
||||
if(cur) { cur.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>'; cur.style.color = 'var(--c-primary)'; }
|
||||
gpText.textContent = '正在生成「'+steps[currentStep].name+'」...';
|
||||
}
|
||||
lucide.createIcons();
|
||||
},600);
|
||||
}
|
||||
|
||||
function goGenerate(name) {
|
||||
const sel = document.querySelector('#screen-generate .form-select');
|
||||
if(sel) sel.value = name;
|
||||
location.href='执行生成.html';
|
||||
}
|
||||
|
||||
function onTplSelect() {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,656 @@
|
|||
<!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>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
:root{
|
||||
--c-primary:#5b5bd6; --c-primary-hover:#4a4ac0; --c-primary-soft:#eeeefb;
|
||||
--c-primary-2:#7c7cdb; --c-primary-dark:#3b3ba8;
|
||||
--c-bg:#f5f6f8; --c-bg-card:#ffffff; --c-bg-soft:#f0f1f3; --c-bg-hover:#eaecef;
|
||||
--c-border:#e0e2e6; --c-border-light:#eaecef;
|
||||
--c-text:#1a1d24; --c-text-2:#5b626e; --c-text-3:#9aa1ad; --c-text-4:#c4c8cf;
|
||||
--c-success:#1a8c4a; --c-warning:#d48a00; --c-danger:#d33; --c-info:#4a90d9;
|
||||
--c-orange:#e68a00;
|
||||
--font:-apple-system,"PingFang SC","Microsoft YaHei","Segoe UI",Roboto,sans-serif;
|
||||
--fs-h1:22px; --fs-h2:17px; --fs-h3:15px; --fs-body:14px; --fs-sm:13px; --fs-xs:12px;
|
||||
--fw-bold:600; --fw-medium:500;
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
|
||||
--radius:8px; --radius-sm:6px; --radius-lg:12px;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-md:0 4px 12px rgba(0,0,0,.08);
|
||||
--shadow-lg:0 8px 24px rgba(0,0,0,.10);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:var(--font);font-size:var(--fs-body);color:var(--c-text);background:var(--c-bg);line-height:1.6;-webkit-font-smoothing:antialiased;height:100vh;overflow:hidden}
|
||||
a{color:var(--c-primary);text-decoration:none}
|
||||
[data-lucide]{width:16px;height:16px;stroke-width:1.8;vertical-align:middle;color:inherit;flex-shrink:0}
|
||||
.icon-sm{width:14px;height:14px}
|
||||
.icon-lg{width:20px;height:20px}
|
||||
/* === top bar === */
|
||||
.app{display:flex;flex-direction:column;height:100vh}
|
||||
.topbar{height:52px;background:var(--c-bg-card);border-bottom:1px solid var(--c-border);display:flex;align-items:center;padding:0 var(--sp-5);flex-shrink:0;gap:var(--sp-3)}
|
||||
.topbar-logo{display:flex;align-items:center;gap:var(--sp-2);font-weight:var(--fw-bold);font-size:var(--fs-h3);color:var(--c-text);margin-right:var(--sp-4);flex-shrink:0}
|
||||
.logo-icon{width:28px;height:28px;background:var(--c-primary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700}
|
||||
.topbar-nav{display:flex;align-items:center;gap:var(--sp-1)}
|
||||
.topbar-tab{padding:var(--sp-2) var(--sp-4);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);border-radius:var(--radius-sm);transition:all .12s;user-select:none;display:flex;align-items:center;gap:var(--sp-2);white-space:nowrap}
|
||||
.topbar-tab:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.topbar-tab.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:var(--sp-3)}
|
||||
.user-badge{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);background:var(--c-bg-soft);border-radius:20px;cursor:pointer}
|
||||
.user-avatar-sm{width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold)}
|
||||
/* === main layout === */
|
||||
.main-area{flex:1;overflow:hidden;display:flex}
|
||||
.screen{display:none;flex:1;overflow:hidden}
|
||||
.screen.active{display:flex}
|
||||
.scroll-area{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6)}
|
||||
/* === breadcrumb === */
|
||||
.breadcrumb{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-sm);color:var(--c-text-3);margin-bottom:var(--sp-4);flex-wrap:wrap}
|
||||
.breadcrumb span,.breadcrumb a{color:var(--c-text-2)}
|
||||
/* === card === */
|
||||
.card{background:var(--c-bg-card);border-radius:var(--radius-lg);border:1px solid var(--c-border);overflow:hidden}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.card-title{font-size:var(--fs-h3);font-weight:var(--fw-bold);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.card-body{padding:var(--sp-5)}
|
||||
/* === buttons === */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 18px;border-radius:var(--radius);font-size:var(--fs-sm);font-weight:var(--fw-medium);cursor:pointer;border:1px solid transparent;transition:all .12s;line-height:1.4;white-space:nowrap}
|
||||
.btn-primary{background:var(--c-primary);color:#fff;border-color:var(--c-primary)}
|
||||
.btn-primary:hover{background:var(--c-primary-hover)}
|
||||
.btn-outline{background:transparent;color:var(--c-text);border-color:var(--c-border)}
|
||||
.btn-outline:hover{background:var(--c-bg-soft);border-color:var(--c-text-3)}
|
||||
.btn-ghost{background:transparent;color:var(--c-text-2);border-color:transparent;padding:6px 10px}
|
||||
.btn-ghost:hover{background:var(--c-bg-soft)}
|
||||
.btn-success{background:var(--c-success);color:#fff}
|
||||
.btn-success:hover{background:#157a3e}
|
||||
.btn-sm{padding:5px 12px;font-size:var(--fs-xs)}
|
||||
.btn-xs{padding:3px 8px;font-size:11px;border-radius:var(--radius-sm)}
|
||||
/* === form === */
|
||||
.form-group{margin-bottom:var(--sp-4)}
|
||||
.form-label{display:block;font-size:var(--fs-sm);font-weight:var(--fw-medium);color:var(--c-text-2);margin-bottom:var(--sp-1)}
|
||||
.form-input,.form-select{width:100%;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;transition:border-color .15s}
|
||||
.form-input:focus,.form-select:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
.form-textarea{width:100%;min-height:80px;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);resize:vertical;outline:none}
|
||||
.form-textarea:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
/* === template list === */
|
||||
.template-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--sp-4)}
|
||||
.tpl-card{border:1px solid var(--c-border-light);border-radius:var(--radius-lg);background:var(--c-bg-card);padding:var(--sp-5);cursor:pointer;transition:all .12s}
|
||||
.tpl-card:hover{box-shadow:var(--shadow-md);border-color:var(--c-primary-2)}
|
||||
.tpl-card .tpl-name{font-size:var(--fs-body);font-weight:var(--fw-bold);margin-bottom:var(--sp-1);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.tpl-card .tpl-desc{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-3)}
|
||||
.tpl-card .tpl-meta{display:flex;align-items:center;gap:var(--sp-3);font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.tpl-card .tpl-actions{margin-top:var(--sp-3);display:flex;gap:var(--sp-2)}
|
||||
.tpl-status{font-size:10px;padding:2px 10px;border-radius:10px;display:inline-block}
|
||||
.tpl-status.ready{background:#e8f5e9;color:var(--c-success)}
|
||||
.tpl-status.editing{background:#fff3e0;color:var(--c-warning)}
|
||||
/.tpl-status.empty{background:var(--c-bg-soft);color:var(--c-text-3)}
|
||||
/* === template editor LAYOUT (left+right split) === */
|
||||
.tpl-editor{display:flex;flex:1;overflow:hidden}
|
||||
.tpl-left{width:380px;flex-shrink:0;border-right:1px solid var(--c-border);background:var(--c-bg-card);display:flex;flex-direction:column;overflow:hidden}
|
||||
.tpl-left-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.tpl-left-scroll{flex:1;overflow-y:auto;padding:var(--sp-4)}
|
||||
.tpl-right{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--c-bg)}
|
||||
.tpl-right-toolbar{display:flex;align-items:center;padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--c-border);background:var(--c-bg-card);flex-shrink:0;gap:var(--sp-2)}
|
||||
.tpl-right-body{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6);display:flex;justify-content:center}
|
||||
/* === mode toggle === */
|
||||
.mode-group{display:flex;border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;flex-shrink:0}
|
||||
.mode-btn{padding:4px 14px;cursor:pointer;font-size:var(--fs-xs);color:var(--c-text-2);background:var(--c-bg-card);border:none;border-right:1px solid var(--c-border);transition:all .12s}
|
||||
.mode-btn:last-child{border-right:none}
|
||||
.mode-btn.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.mode-btn:hover:not(.active){background:var(--c-bg-soft)}
|
||||
/* === paragraph markers === */
|
||||
.doc-edit-page{width:794px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:80px 72px 120px;min-height:500px;height:auto;font-size:14px;line-height:1.8;position:relative}
|
||||
.doc-edit-page .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.doc-edit-page .para-block{position:relative;padding:var(--sp-2);margin:var(--sp-1) 0;border-radius:var(--radius-sm);border:2px solid transparent;cursor:pointer;transition:all .12s}
|
||||
.doc-edit-page .para-block:hover{border-color:var(--c-border)}
|
||||
.doc-edit-page .para-block.selected{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.doc-edit-page .para-block .para-tag{position:absolute;right:4px;top:4px;font-size:9px;padding:1px 6px;border-radius:4px;display:none}
|
||||
.doc-edit-page .para-block:hover .para-tag,.doc-edit-page .para-block.selected .para-tag{display:block}
|
||||
.doc-edit-page .para-block .para-tag.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.doc-edit-page .para-block .para-tag.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
.doc-edit-page table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.doc-edit-page th,.doc-edit-page td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.doc-edit-page th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === edit mode rich editor === */
|
||||
.edit-toolbar{display:flex;align-items:center;gap:var(--sp-1);padding:var(--sp-2) var(--sp-3);border:1px solid var(--c-border);border-bottom:none;border-radius:var(--radius) var(--radius) 0 0;background:var(--c-bg-card);flex-wrap:wrap;flex-shrink:0}
|
||||
.edit-toolbar .tb-divider{width:1px;height:18px;background:var(--c-border);margin:0 var(--sp-1)}
|
||||
.edit-toolbar .tb-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);cursor:pointer;color:var(--c-text-2);font-size:var(--fs-sm)}
|
||||
.edit-toolbar .tb-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.edit-toolbar .tb-btn.active{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.edit-body{border:1px solid var(--c-border);border-radius:0 0 var(--radius) var(--radius);padding:var(--sp-5) var(--sp-6);background:var(--c-bg-card);min-height:500px;outline:none;font-size:14px;line-height:1.8;color:var(--c-text);width:794px}
|
||||
.edit-body:focus{border-color:var(--c-primary)}
|
||||
.edit-body .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.edit-body .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.edit-body .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.edit-body table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.edit-body th,.edit-body td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.edit-body th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === left panel config === */
|
||||
.config-section{margin-bottom:var(--sp-5)}
|
||||
.config-section-title{font-size:var(--fs-sm);font-weight:var(--fw-bold);color:var(--c-text);margin-bottom:var(--sp-3);padding-bottom:var(--sp-2);border-bottom:1px solid var(--c-border-light)}
|
||||
.config-field{margin-bottom:var(--sp-3)}
|
||||
.config-field .cf-label{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-1)}
|
||||
.config-field .cf-value{font-size:var(--fs-sm);color:var(--c-text)}
|
||||
.cfg-toggle{display:flex;align-items:center;gap:var(--sp-2);cursor:pointer;user-select:none;padding:var(--sp-1) 0}
|
||||
.toggle-switch{width:32px;height:18px;border-radius:9px;background:var(--c-border);position:relative;cursor:pointer;transition:background .2s;flex-shrink:0}
|
||||
.toggle-switch.on{background:var(--c-primary)}
|
||||
.toggle-switch::after{content:'';width:14px;height:14px;border-radius:50%;background:#fff;position:absolute;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 2px rgba(0,0,0,.15)}
|
||||
.toggle-switch.on::after{transform:translateX(14px)}
|
||||
.cfg-select{padding:6px 10px;border:1px solid var(--c-border);border-radius:var(--radius-sm);font-size:var(--fs-sm);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;width:100%}
|
||||
.cfg-select:focus{border-color:var(--c-primary)}
|
||||
/* === left panel paragraph list (edit mode) === */
|
||||
.para-list-item{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);transition:all .12s;margin-bottom:2px}
|
||||
.para-list-item:hover{background:var(--c-bg-soft)}
|
||||
.para-list-item.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.para-list-item .pli-index{width:20px;height:20px;border-radius:50%;background:var(--c-bg-soft);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold);color:var(--c-text-3);flex-shrink:0}
|
||||
.para-list-item.active .pli-index{background:var(--c-primary);color:#fff}
|
||||
.para-list-item .pli-badge{font-size:9px;padding:0 6px;border-radius:6px;margin-left:auto;flex-shrink:0}
|
||||
.para-list-item .pli-badge.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.para-list-item .pli-badge.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
/* === gen progress & hints === */
|
||||
.gen-progress{background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.spinner{width:20px;height:20px;border:2px solid var(--c-border);border-top-color:var(--c-primary);border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
/* === upload zone === */
|
||||
.upload-zone{border:2px dashed var(--c-border);border-radius:var(--radius-lg);padding:var(--sp-6) var(--sp-4);text-align:center;cursor:pointer;transition:all .15s;background:var(--c-bg-card)}
|
||||
.upload-zone:hover{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.upload-icon{width:48px;height:48px;background:var(--c-bg-soft);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto var(--sp-3);color:var(--c-text-3)}
|
||||
.uploaded-file{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--c-bg-soft);border-radius:var(--radius);margin-top:var(--sp-3)}
|
||||
.uploaded-file .file-icon.excel{background:#e8f5e9;color:#2e7d32}
|
||||
/* === model card === */
|
||||
.model-card{border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-3);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.model-card .mc-icon{width:40px;height:40px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:16px;font-weight:var(--fw-bold);color:#fff}
|
||||
.model-card .mc-info{flex:1;min-width:0}
|
||||
.model-card .mc-name{font-size:var(--fs-sm);font-weight:var(--fw-medium)}
|
||||
.model-card .mc-provider{font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.model-card .mc-status{font-size:var(--fs-xs)}
|
||||
.model-card .mc-status.on{color:var(--c-success)}
|
||||
.model-card .mc-status.off{color:var(--c-text-3)}
|
||||
/* === test result modal === */
|
||||
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:1000;align-items:center;justify-content:center}
|
||||
.modal-overlay.open{display:flex}
|
||||
.modal{background:var(--c-bg-card);border-radius:var(--radius-lg);width:640px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
|
||||
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border);font-size:var(--fs-h3);font-weight:var(--fw-bold)}
|
||||
.modal-header .close-btn{cursor:pointer;color:var(--c-text-3);padding:4px;border-radius:var(--radius-sm)}
|
||||
.modal-header .close-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.modal-body{padding:var(--sp-5)}
|
||||
.modal-footer{display:flex;justify-content:flex-end;gap:var(--sp-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--c-border-light)}
|
||||
/* === typography === */
|
||||
.text-xs{font-size:var(--fs-xs)}
|
||||
.text-sm{font-size:var(--fs-sm)}
|
||||
.text-muted{color:var(--c-text-3)}
|
||||
.flex{display:flex}
|
||||
.flex-1{flex:1}
|
||||
.gap-2{gap:var(--sp-2)}
|
||||
.gap-3{gap:var(--sp-3)}
|
||||
.items-center{align-items:center}
|
||||
.justify-between{justify-content:space-between}
|
||||
.mb-3{margin-bottom:var(--sp-3)}
|
||||
.mb-4{margin-bottom:var(--sp-4)}
|
||||
.mt-3{margin-top:var(--sp-3)}
|
||||
.p-3{padding:var(--sp-3)}
|
||||
.p-4{padding:var(--sp-4)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="topbar-logo"><div class="logo-icon">A</div><span>AI 文档模板</span></div>
|
||||
<a class="topbar-tab" href="模板管理.html"><i data-lucide="layout-dashboard" class="icon-sm"></i> 模板管理</a>
|
||||
<a class="topbar-tab active" href="模型管理.html"><i data-lucide="cpu" class="icon-sm"></i> 模型管理</a>
|
||||
<a class="topbar-tab" href="执行生成.html"><i data-lucide="zap" class="icon-sm"></i> 执行生成</a>
|
||||
<a class="topbar-tab" href="生成记录.html"><i data-lucide="clock" class="icon-sm"></i> 生成记录</a>
|
||||
<div class="topbar-right">
|
||||
<span class="text-xs text-muted">v3.0</span>
|
||||
<div class="user-badge"><span class="text-xs" style="font-weight:var(--fw-medium)">周文涛</span><div class="user-avatar-sm">周</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="screen active" id="screen-model">
|
||||
<div class="scroll-area">
|
||||
<div class="breadcrumb"><i data-lucide="home" class="icon-sm"></i> 模型管理 / <span>AI 模型配置</span></div>
|
||||
<div class="card" style="margin-bottom:var(--sp-4)">
|
||||
<div class="card-header"><span class="card-title"><i data-lucide="cpu" style="color:var(--c-primary)"></i>可用模型列表</span><button class="btn btn-outline btn-sm" onclick="openModelModal('add')"><i data-lucide="plus"></i>添加模型</button></div>
|
||||
<div class="card-body">
|
||||
<div class="model-card"><div class="mc-icon" style="background:#e3f2fd;color:#1565c0">C</div><div class="mc-info"><div class="mc-name">Claude 3.5 Sonnet</div><div class="mc-provider">Anthropic · api.anthropic.com</div></div><div class="mc-status on"><i data-lucide="check-circle" class="icon-sm"></i> 已启用</div><button class="btn btn-ghost btn-sm" onclick="openModelModal('edit', 'Claude 3.5 Sonnet', 'Anthropic', 'api.anthropic.com', 'sk-ant-****')"><i data-lucide="settings" class="icon-sm"></i></button></div>
|
||||
<div class="model-card"><div class="mc-icon" style="background:#e8f5e9;color:#2e7d32">G</div><div class="mc-info"><div class="mc-name">GPT-4o</div><div class="mc-provider">OpenAI · api.openai.com</div></div><div class="mc-status on"><i data-lucide="check-circle" class="icon-sm"></i> 已启用</div><button class="btn btn-ghost btn-sm" onclick="openModelModal('edit', 'GPT-4o', 'OpenAI', 'api.openai.com', 'sk-proj-****')"><i data-lucide="settings" class="icon-sm"></i></button></div>
|
||||
<div class="model-card"><div class="mc-icon" style="background:#fff3e0;color:#e65100">D</div><div class="mc-info"><div class="mc-name">DeepSeek-V3</div><div class="mc-provider">DeepSeek · api.deepseek.com</div></div><div class="mc-status on"><i data-lucide="check-circle" class="icon-sm"></i> 已启用</div><button class="btn btn-ghost btn-sm" onclick="openModelModal('edit', 'DeepSeek-V3', 'DeepSeek', 'api.deepseek.com', 'sk-ds-****')"><i data-lucide="settings" class="icon-sm"></i></button></div>
|
||||
<div class="model-card" style="opacity:.6"><div class="mc-icon" style="background:#f3e5f5;color:#7b1fa2">Q</div><div class="mc-info"><div class="mc-name">通义千问 Max</div><div class="mc-provider">阿里云 · dashscope.aliyuncs.com</div></div><div class="mc-status off"><i data-lucide="power" class="icon-sm"></i> 已禁用</div><button class="btn btn-ghost btn-sm" onclick="openModelModal('edit', '通义千问 Max', '阿里云', 'dashscope.aliyuncs.com', 'sk-****')"><i data-lucide="settings" class="icon-sm"></i></button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title"><i data-lucide="sliders" style="color:var(--c-primary)"></i>系统默认模型分配</span><button class="btn btn-primary btn-sm" onclick="alert('已保存')"><i data-lucide="save"></i>保存配置</button></div>
|
||||
<div class="card-body">
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-4)">
|
||||
<div class="form-group"><label class="form-label">默认文本生成模型</label><select class="form-select"><option selected>Claude 3.5 Sonnet</option><option>GPT-4o</option><option>DeepSeek-V3</option></select></div>
|
||||
<div class="form-group"><label class="form-label">表格生成模型</label><select class="form-select"><option>Claude 3.5 Sonnet</option><option selected>GPT-4o</option><option>DeepSeek-V3</option></select></div>
|
||||
<div class="form-group"><label class="form-label">图表生成模型</label><select class="form-select"><option>Claude 3.5 Sonnet</option><option>GPT-4o</option><option selected>DeepSeek-V3</option></select></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model add/edit modal -->
|
||||
<div class="modal-overlay" id="modelModal">
|
||||
<div class="modal" style="width:520px">
|
||||
<div class="modal-header">
|
||||
<span id="modelModalTitle"><i data-lucide="cpu" class="icon-sm" style="color:var(--c-primary)"></i> 添加模型</span>
|
||||
<span class="close-btn" onclick="closeModelModal()"><i data-lucide="x" class="icon-sm"></i></span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="config-field mb-3">
|
||||
<div class="cf-label">模型名称</div>
|
||||
<input class="form-input" id="mName" placeholder="例如:Claude 3.5 Sonnet" value="Claude 3.5 Sonnet">
|
||||
</div>
|
||||
<div class="config-field mb-3">
|
||||
<div class="cf-label">供应厂商</div>
|
||||
<input class="form-input" id="mProvider" placeholder="例如:Anthropic" value="Anthropic">
|
||||
</div>
|
||||
<div class="config-field mb-3">
|
||||
<div class="cf-label">模型格式</div>
|
||||
<select class="form-select" id="mFormat">
|
||||
<option value="Anthropic" selected>Anthropic(Claude 消息格式)</option>
|
||||
<option value="OpenAI">OpenAI(Chat Completion 格式)</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1)">选择该模型使用的 API 请求格式,系统将按对应协议调用</div>
|
||||
</div>
|
||||
<div class="config-field mb-3">
|
||||
<div class="cf-label">API 接口地址</div>
|
||||
<input class="form-input" id="mEndpoint" placeholder="例如:https://api.anthropic.com" value="https://api.anthropic.com">
|
||||
</div>
|
||||
<div class="config-field mb-3">
|
||||
<div class="cf-label">API Key</div>
|
||||
<div style="display:flex;gap:var(--sp-2)">
|
||||
<input class="form-input" id="mApiKey" type="password" value="sk-ant-xxxxxxxxxxxx" style="flex:1">
|
||||
<button class="btn btn-ghost btn-sm" onclick="const inp=document.getElementById('mApiKey');inp.type=inp.type==='password'?'text':'password'"><i data-lucide="eye" class="icon-sm"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field mb-3">
|
||||
<div class="cf-label">状态</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch on"></div>
|
||||
<span class="text-xs">已启用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeModelModal()">取消</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="alert('模型配置已保存');closeModelModal()"><i data-lucide="save" class="icon-sm"></i>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// === Screen switch ===
|
||||
function switchScreen(el, id) {
|
||||
// If called with a single string argument, treat it as screen id
|
||||
if(!id && typeof el === 'string') {
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
document.getElementById(el).classList.add('active');
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
// === Template list nav ===
|
||||
function backToTplList() {
|
||||
document.getElementById('tplList').style.display = 'flex';
|
||||
document.getElementById('tplEditor').style.display = 'none';
|
||||
}
|
||||
function editTemplate(name) {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = name;
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
function startNewTemplate() {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = '新建模板';
|
||||
alert('模拟:上传 Word 模板弹窗');
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
// === 3-column editor functions ===
|
||||
const paraData = [
|
||||
{id:1, name:'标题:2025年Q1报告', badge:'manual', label:'人工编辑'},
|
||||
{id:2, name:'一、主要经营指标完成情况', badge:'ai', label:'AI 生成'},
|
||||
{id:3, name:'主要经营指标完成情况表', badge:'ai', label:'AI 生成'},
|
||||
{id:4, name:'二、成本费用分析', badge:'ai', label:'AI 生成'},
|
||||
{id:5, name:'三、存在的主要问题', badge:'ai', label:'AI 生成'},
|
||||
{id:6, name:'四、下一步工作措施', badge:'ai', label:'AI 生成'},
|
||||
{id:7, name:'封面信息', badge:'manual', label:'人工编辑'},
|
||||
{id:8, name:'附图:各季度营收趋势图', badge:'ai', label:'AI 生成'},
|
||||
];
|
||||
|
||||
const paraConfigs = {
|
||||
1: {name:'一、主要经营指标完成情况', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'请根据上传的财务数据,生成经营指标完成情况章节,要求包括营业收入、利润总额等核心指标的完成值、同比变化及原因分析。', needPrompt:true, needFile:true, format:'正式报告段落', note:'需上传财务部提供的季度财务数据报表(Excel格式,含各指标完成值)'},
|
||||
2: {name:'主要经营指标完成情况表', mode:'AI 生成', model:'GPT-4o', prompt:'根据上传的各部门数据,生成指标完成情况表,包含指标名、完成值、同比、完成率、责任部门。', needPrompt:true, needFile:true, format:'表格形式', note:'需上传各部门经营数据汇总表(Excel格式,含指标明细)'},
|
||||
3: {name:'二、成本费用分析', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'根据财务数据生成成本费用分析内容,包括各项成本构成、同比变化及原因。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
4: {name:'三、存在的主要问题', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'基于经营数据和报告要求,分析当前存在的主要问题和风险。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
5: {name:'四、下一步工作措施', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'针对问题生成具体可执行的工作措施。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
6: {name:'封面信息', mode:'人工编辑', model:'—', prompt:'', needPrompt:false, needFile:false, format:'—', note:''},
|
||||
};
|
||||
|
||||
function initParaList() {
|
||||
let html = '';
|
||||
paraData.forEach(p => {
|
||||
html += '<div class="para-list-item" id="leftParaItem'+p.id+'" onclick="selectPara('+p.id+')">'
|
||||
+ '<span class="pli-index">'+p.id+'</span>'
|
||||
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+p.name+'</span>'
|
||||
+ '<span class="pli-badge '+p.badge+'">'+p.label+'</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
document.getElementById('leftParaList').innerHTML = html;
|
||||
}
|
||||
|
||||
function selectPara(id) {
|
||||
// Update left list
|
||||
document.querySelectorAll('.para-list-item').forEach(e => e.classList.remove('active'));
|
||||
const leftItem = document.getElementById('leftParaItem'+id);
|
||||
if(leftItem) leftItem.classList.add('active');
|
||||
// Scroll center to paragraph
|
||||
const block = document.getElementById('paraBlock'+id);
|
||||
if(block) {
|
||||
block.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
document.querySelectorAll('.para-block').forEach(b => b.classList.remove('selected'));
|
||||
block.classList.add('selected');
|
||||
}
|
||||
// Update right panel
|
||||
buildRightPanel(id);
|
||||
}
|
||||
|
||||
function selectParaFromDoc(id) {
|
||||
selectPara(id);
|
||||
}
|
||||
|
||||
function buildRightPanel(id) {
|
||||
const cfg = paraConfigs[id] || paraConfigs[1];
|
||||
const isAi = cfg.mode === 'AI 生成';
|
||||
document.getElementById('rightPanelTitle').textContent = '段落配置';
|
||||
document.getElementById('rightParaName').textContent = '第 '+id+' 段 · '+(cfg.name||'');
|
||||
const html = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">段落信息</div>
|
||||
<div class="config-field"><div class="cf-label">段落名称</div><div class="cf-value" style="font-weight:var(--fw-medium)">${cfg.name}</div></div>
|
||||
<div class="config-field"><div class="cf-label">所处位置</div><div class="cf-value text-sm text-muted">第 ${id} 段</div></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">生成配置</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">编辑方式</div>
|
||||
<select class="cfg-select" onchange="onEditModeChange(this,${id})">
|
||||
<option value="人工编辑" ${cfg.mode==='人工编辑'?'selected':''}>人工编辑</option>
|
||||
<option value="AI 生成" ${cfg.mode==='AI 生成'?'selected':''}>AI 生成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rmodelField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">生成模型</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.model==='Claude 3.5 Sonnet'?'selected':''}>Claude 3.5 Sonnet</option>
|
||||
<option ${cfg.model==='GPT-4o'?'selected':''}>GPT-4o</option>
|
||||
<option ${cfg.model==='DeepSeek-V3'?'selected':''}>DeepSeek-V3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rformatField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">输出格式</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.format==='正式报告段落'?'selected':''}>正式报告段落</option>
|
||||
<option ${cfg.format==='表格形式'?'selected':''}>表格形式</option>
|
||||
<option ${cfg.format==='分条列出'?'selected':''}>分条列出</option>
|
||||
<option ${cfg.format==='图表形式'?'selected':''}>图表形式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要提示词</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needPrompt?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needPrompt?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rpromptField${id}" style="${cfg.needPrompt?'':'display:none'}">
|
||||
<div class="cf-label">预设提示词 <span class="text-xs text-muted">(编辑模板时设置)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:80px;font-size:var(--fs-sm)">${cfg.prompt || '请在此输入提示词...'}</textarea>
|
||||
<div class="text-xs text-muted mt-3">提示词在模板编辑时预设,生成时可确认或微调</div>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要参考文件</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needFile?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needFile?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rnoteField${id}" style="${cfg.needFile?'':'display:none'}">
|
||||
<div class="cf-label">备注说明 <span class="text-xs text-muted">(执行生成时提示需上传什么文件)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:60px;font-size:var(--fs-sm)" placeholder="例如:需上传财务部提供的季度财务数据报表">${cfg.note || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
${isAi ? `
|
||||
<div id="rtestBtn${id}">
|
||||
<button class="btn btn-primary btn-block" style="width:100%;justify-content:center" onclick="testParagraph(${id})">
|
||||
<i data-lucide="zap" class="icon-sm"></i> 立即测试
|
||||
</button>
|
||||
<div class="text-xs text-muted" style="text-align:center;margin-top:var(--sp-2)">针对此段落单独测试 · 需上传附件 → 文件解析 → 请求AI → 填充预览</div>
|
||||
</div>
|
||||
` : `
|
||||
<div style="background:var(--c-bg-soft);padding:var(--sp-3);border-radius:var(--radius);text-align:center">
|
||||
<span class="text-xs text-muted">该段落为「人工编辑」,无需 AI 测试</span>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
document.getElementById('rightPanelBody').innerHTML = html;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function onEditModeChange(sel, paraId) {
|
||||
const isAi = sel.value === 'AI 生成';
|
||||
document.getElementById('rmodelField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rformatField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rpromptField'+paraId).style.display = 'block';
|
||||
const testBtn = document.getElementById('rtestBtn'+paraId);
|
||||
if(testBtn) testBtn.style.display = isAi ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// === Toggle switch ===
|
||||
function toggleSw(el) {
|
||||
const sw = el.querySelector('.toggle-switch');
|
||||
const txt = el.querySelector('.text-xs');
|
||||
sw.classList.toggle('on');
|
||||
if(txt) txt.textContent = sw.classList.contains('on') ? '是' : '否';
|
||||
// Show/hide note field (sibling config-field with noteField id)
|
||||
const noteField = el.closest('.config-field')?.nextElementSibling;
|
||||
if(noteField && noteField.id && noteField.id.startsWith('noteField')) {
|
||||
noteField.style.display = sw.classList.contains('on') ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// === Test paragraph - 3 step flow ===
|
||||
function testParagraph(id) {
|
||||
// Reset modal to step 1
|
||||
document.getElementById('testStep1').style.display = 'block';
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'none';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
document.getElementById('testUploaded').style.display = 'none';
|
||||
document.getElementById('testModal').classList.add('open');
|
||||
document.querySelector('#testStep1 .config-section-title span').style.background = 'var(--c-primary)';
|
||||
}
|
||||
|
||||
function simulateUpload() {
|
||||
document.querySelector('#testStep1 > div:first-child > div > div').style.display = 'none'; // hide upload box
|
||||
document.getElementById('testUploaded').style.display = 'block';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function startTestStep2() {
|
||||
document.getElementById('testStep1').style.display = 'none';
|
||||
document.getElementById('testStep2').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
// Reset task icons
|
||||
['tsk1','tsk2','tsk3'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.innerHTML = '<i data-lucide="circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-text-4)';
|
||||
});
|
||||
lucide.createIcons();
|
||||
|
||||
// Simulate step progression
|
||||
const t1 = document.getElementById('tsk1');
|
||||
const t2 = document.getElementById('tsk2');
|
||||
const t3 = document.getElementById('tsk3');
|
||||
const stepText = document.getElementById('testStepText');
|
||||
|
||||
// Step 1: File parsing (after 1.2s)
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在解析文件...';
|
||||
lucide.createIcons();
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-success)';
|
||||
// Step 2: AI request
|
||||
t2.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在请求 AI 大模型(Claude 3.5 Sonnet)...';
|
||||
lucide.createIcons();
|
||||
}, 1400);
|
||||
|
||||
setTimeout(() => {
|
||||
t2.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-success)';
|
||||
// Step 3: Fill result
|
||||
t3.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在填充生成结果...';
|
||||
lucide.createIcons();
|
||||
}, 3200);
|
||||
|
||||
setTimeout(() => {
|
||||
t3.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-success)';
|
||||
// Switch to step 3
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
}, 4800);
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
document.getElementById('testModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Model management modals ===
|
||||
function openModelModal(mode, name, provider, endpoint, apikey) {
|
||||
const title = document.getElementById('modelModalTitle');
|
||||
const mName = document.getElementById('mName');
|
||||
const mProvider = document.getElementById('mProvider');
|
||||
const mFormat = document.getElementById('mFormat');
|
||||
const mEndpoint = document.getElementById('mEndpoint');
|
||||
const mApiKey = document.getElementById('mApiKey');
|
||||
if(mode === 'add') {
|
||||
title.innerHTML = '<i data-lucide="cpu" class="icon-sm" style="color:var(--c-primary)"></i> 添加模型';
|
||||
mName.value = '';
|
||||
mProvider.value = '';
|
||||
mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = '';
|
||||
mApiKey.value = '';
|
||||
} else {
|
||||
title.innerHTML = '<i data-lucide="settings" class="icon-sm" style="color:var(--c-primary)"></i> 编辑模型 - ' + name;
|
||||
mName.value = name || '';
|
||||
mProvider.value = provider || '';
|
||||
// Guess format from provider name
|
||||
if(provider && provider.toLowerCase().includes('anthropic')) mFormat.value = 'Anthropic';
|
||||
else if(provider && (provider.toLowerCase().includes('openai') || provider.toLowerCase().includes('deepseek') || provider.toLowerCase().includes('阿里'))) mFormat.value = 'OpenAI';
|
||||
else mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = endpoint || '';
|
||||
mApiKey.value = apikey || '';
|
||||
}
|
||||
document.getElementById('modelModal').classList.add('open');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeModelModal() {
|
||||
document.getElementById('modelModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Generation ===
|
||||
function startGeneration() {
|
||||
document.getElementById('genLeftStatus').style.display = 'block';
|
||||
document.getElementById('genDoneSide').style.display = 'none';
|
||||
let pct = 0;
|
||||
const bar = document.getElementById('genProgressBar');
|
||||
const gpText = document.getElementById('genLeftText');
|
||||
const steps = [
|
||||
{id:'g1', name:'一、主要经营指标完成情况'},
|
||||
{id:'g2', name:'主要经营指标完成情况表'},
|
||||
{id:'g3', name:'二、成本费用分析'},
|
||||
{id:'g4', name:'三、存在的主要问题'},
|
||||
{id:'g5', name:'四、下一步工作措施'},
|
||||
{id:'g6', name:'附图:各季度营收趋势图'}
|
||||
];
|
||||
let currentStep = 0;
|
||||
const interval = setInterval(() => {
|
||||
pct += Math.floor(Math.random()*8)+3;
|
||||
if(pct>=100) {
|
||||
pct=100;
|
||||
clearInterval(interval);
|
||||
document.getElementById('genLeftStatus').style.display = 'none';
|
||||
document.getElementById('genDoneSide').style.display = 'flex';
|
||||
// Mark all remaining as done
|
||||
for(let i=currentStep;i<steps.length;i++) {
|
||||
const el = document.getElementById(steps[i].id);
|
||||
el.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-success)';
|
||||
}
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
bar.style.width = pct+'%';
|
||||
// Update step progress
|
||||
const stepIdx = Math.floor(pct / 17);
|
||||
if(stepIdx > currentStep && stepIdx < steps.length) {
|
||||
currentStep = stepIdx;
|
||||
// Mark previous as done
|
||||
if(currentStep > 0) {
|
||||
const prev = document.getElementById(steps[currentStep-1].id);
|
||||
if(prev) { prev.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>'; prev.style.color = 'var(--c-success)'; }
|
||||
}
|
||||
// Mark current as in progress
|
||||
const cur = document.getElementById(steps[currentStep].id);
|
||||
if(cur) { cur.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>'; cur.style.color = 'var(--c-primary)'; }
|
||||
gpText.textContent = '正在生成「'+steps[currentStep].name+'」...';
|
||||
}
|
||||
lucide.createIcons();
|
||||
},600);
|
||||
}
|
||||
|
||||
function goGenerate(name) {
|
||||
const sel = document.querySelector('#screen-generate .form-select');
|
||||
if(sel) sel.value = name;
|
||||
location.href='执行生成.html';
|
||||
}
|
||||
|
||||
function onTplSelect() {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,792 @@
|
|||
<!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>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
:root{
|
||||
--c-primary:#5b5bd6; --c-primary-hover:#4a4ac0; --c-primary-soft:#eeeefb;
|
||||
--c-primary-2:#7c7cdb; --c-primary-dark:#3b3ba8;
|
||||
--c-bg:#f5f6f8; --c-bg-card:#ffffff; --c-bg-soft:#f0f1f3; --c-bg-hover:#eaecef;
|
||||
--c-border:#e0e2e6; --c-border-light:#eaecef;
|
||||
--c-text:#1a1d24; --c-text-2:#5b626e; --c-text-3:#9aa1ad; --c-text-4:#c4c8cf;
|
||||
--c-success:#1a8c4a; --c-warning:#d48a00; --c-danger:#d33; --c-info:#4a90d9;
|
||||
--c-orange:#e68a00;
|
||||
--font:-apple-system,"PingFang SC","Microsoft YaHei","Segoe UI",Roboto,sans-serif;
|
||||
--fs-h1:22px; --fs-h2:17px; --fs-h3:15px; --fs-body:14px; --fs-sm:13px; --fs-xs:12px;
|
||||
--fw-bold:600; --fw-medium:500;
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
|
||||
--radius:8px; --radius-sm:6px; --radius-lg:12px;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-md:0 4px 12px rgba(0,0,0,.08);
|
||||
--shadow-lg:0 8px 24px rgba(0,0,0,.10);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:var(--font);font-size:var(--fs-body);color:var(--c-text);background:var(--c-bg);line-height:1.6;-webkit-font-smoothing:antialiased;height:100vh;overflow:hidden}
|
||||
a{color:var(--c-primary);text-decoration:none}
|
||||
[data-lucide]{width:16px;height:16px;stroke-width:1.8;vertical-align:middle;color:inherit;flex-shrink:0}
|
||||
.icon-sm{width:14px;height:14px}
|
||||
.icon-lg{width:20px;height:20px}
|
||||
/* === top bar === */
|
||||
.app{display:flex;flex-direction:column;height:100vh}
|
||||
.topbar{height:52px;background:var(--c-bg-card);border-bottom:1px solid var(--c-border);display:flex;align-items:center;padding:0 var(--sp-5);flex-shrink:0;gap:var(--sp-3)}
|
||||
.topbar-logo{display:flex;align-items:center;gap:var(--sp-2);font-weight:var(--fw-bold);font-size:var(--fs-h3);color:var(--c-text);margin-right:var(--sp-4);flex-shrink:0}
|
||||
.logo-icon{width:28px;height:28px;background:var(--c-primary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700}
|
||||
.topbar-nav{display:flex;align-items:center;gap:var(--sp-1)}
|
||||
.topbar-tab{padding:var(--sp-2) var(--sp-4);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);border-radius:var(--radius-sm);transition:all .12s;user-select:none;display:flex;align-items:center;gap:var(--sp-2);white-space:nowrap}
|
||||
.topbar-tab:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.topbar-tab.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:var(--sp-3)}
|
||||
.user-badge{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);background:var(--c-bg-soft);border-radius:20px;cursor:pointer}
|
||||
.user-avatar-sm{width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold)}
|
||||
/* === main layout === */
|
||||
.main-area{flex:1;overflow:hidden;display:flex}
|
||||
.screen{display:none;flex:1;overflow:hidden}
|
||||
.screen.active{display:flex}
|
||||
.scroll-area{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6)}
|
||||
/* === breadcrumb === */
|
||||
.breadcrumb{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-sm);color:var(--c-text-3);margin-bottom:var(--sp-4);flex-wrap:wrap}
|
||||
.breadcrumb span,.breadcrumb a{color:var(--c-text-2)}
|
||||
/* === card === */
|
||||
.card{background:var(--c-bg-card);border-radius:var(--radius-lg);border:1px solid var(--c-border);overflow:hidden}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.card-title{font-size:var(--fs-h3);font-weight:var(--fw-bold);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.card-body{padding:var(--sp-5)}
|
||||
/* === buttons === */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 18px;border-radius:var(--radius);font-size:var(--fs-sm);font-weight:var(--fw-medium);cursor:pointer;border:1px solid transparent;transition:all .12s;line-height:1.4;white-space:nowrap}
|
||||
.btn-primary{background:var(--c-primary);color:#fff;border-color:var(--c-primary)}
|
||||
.btn-primary:hover{background:var(--c-primary-hover)}
|
||||
.btn-outline{background:transparent;color:var(--c-text);border-color:var(--c-border)}
|
||||
.btn-outline:hover{background:var(--c-bg-soft);border-color:var(--c-text-3)}
|
||||
.btn-ghost{background:transparent;color:var(--c-text-2);border-color:transparent;padding:6px 10px}
|
||||
.btn-ghost:hover{background:var(--c-bg-soft)}
|
||||
.btn-success{background:var(--c-success);color:#fff}
|
||||
.btn-success:hover{background:#157a3e}
|
||||
.btn-sm{padding:5px 12px;font-size:var(--fs-xs)}
|
||||
.btn-xs{padding:3px 8px;font-size:11px;border-radius:var(--radius-sm)}
|
||||
/* === form === */
|
||||
.form-group{margin-bottom:var(--sp-4)}
|
||||
.form-label{display:block;font-size:var(--fs-sm);font-weight:var(--fw-medium);color:var(--c-text-2);margin-bottom:var(--sp-1)}
|
||||
.form-input,.form-select{width:100%;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;transition:border-color .15s}
|
||||
.form-input:focus,.form-select:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
.form-textarea{width:100%;min-height:80px;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);resize:vertical;outline:none}
|
||||
.form-textarea:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
/* === template list === */
|
||||
.template-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--sp-4)}
|
||||
.tpl-card{border:1px solid var(--c-border-light);border-radius:var(--radius-lg);background:var(--c-bg-card);padding:var(--sp-5);cursor:pointer;transition:all .12s}
|
||||
.tpl-card:hover{box-shadow:var(--shadow-md);border-color:var(--c-primary-2)}
|
||||
.tpl-card .tpl-name{font-size:var(--fs-body);font-weight:var(--fw-bold);margin-bottom:var(--sp-1);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.tpl-card .tpl-desc{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-3)}
|
||||
.tpl-card .tpl-meta{display:flex;align-items:center;gap:var(--sp-3);font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.tpl-card .tpl-actions{margin-top:var(--sp-3);display:flex;gap:var(--sp-2)}
|
||||
.tpl-status{font-size:10px;padding:2px 10px;border-radius:10px;display:inline-block}
|
||||
.tpl-status.ready{background:#e8f5e9;color:var(--c-success)}
|
||||
.tpl-status.editing{background:#fff3e0;color:var(--c-warning)}
|
||||
/.tpl-status.empty{background:var(--c-bg-soft);color:var(--c-text-3)}
|
||||
/* === template editor LAYOUT (left+right split) === */
|
||||
.tpl-editor{display:flex;flex:1;overflow:hidden}
|
||||
.tpl-left{width:380px;flex-shrink:0;border-right:1px solid var(--c-border);background:var(--c-bg-card);display:flex;flex-direction:column;overflow:hidden}
|
||||
.tpl-left-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.tpl-left-scroll{flex:1;overflow-y:auto;padding:var(--sp-4)}
|
||||
.tpl-right{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--c-bg)}
|
||||
.tpl-right-toolbar{display:flex;align-items:center;padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--c-border);background:var(--c-bg-card);flex-shrink:0;gap:var(--sp-2)}
|
||||
.tpl-right-body{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6);display:flex;justify-content:center}
|
||||
/* === mode toggle === */
|
||||
.mode-group{display:flex;border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;flex-shrink:0}
|
||||
.mode-btn{padding:4px 14px;cursor:pointer;font-size:var(--fs-xs);color:var(--c-text-2);background:var(--c-bg-card);border:none;border-right:1px solid var(--c-border);transition:all .12s}
|
||||
.mode-btn:last-child{border-right:none}
|
||||
.mode-btn.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.mode-btn:hover:not(.active){background:var(--c-bg-soft)}
|
||||
/* === paragraph markers === */
|
||||
.doc-edit-page{width:794px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:80px 72px 120px;min-height:500px;height:auto;font-size:14px;line-height:1.8;position:relative}
|
||||
.doc-edit-page .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.doc-edit-page .para-block{position:relative;padding:var(--sp-2);margin:var(--sp-1) 0;border-radius:var(--radius-sm);border:2px solid transparent;cursor:pointer;transition:all .12s}
|
||||
.doc-edit-page .para-block:hover{border-color:var(--c-border)}
|
||||
.doc-edit-page .para-block.selected{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.doc-edit-page .para-block .para-tag{position:absolute;right:4px;top:4px;font-size:9px;padding:1px 6px;border-radius:4px;display:none}
|
||||
.doc-edit-page .para-block:hover .para-tag,.doc-edit-page .para-block.selected .para-tag{display:block}
|
||||
.doc-edit-page .para-block .para-tag.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.doc-edit-page .para-block .para-tag.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
.doc-edit-page table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.doc-edit-page th,.doc-edit-page td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.doc-edit-page th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === edit mode rich editor === */
|
||||
.edit-toolbar{display:flex;align-items:center;gap:var(--sp-1);padding:var(--sp-2) var(--sp-3);border:1px solid var(--c-border);border-bottom:none;border-radius:var(--radius) var(--radius) 0 0;background:var(--c-bg-card);flex-wrap:wrap;flex-shrink:0}
|
||||
.edit-toolbar .tb-divider{width:1px;height:18px;background:var(--c-border);margin:0 var(--sp-1)}
|
||||
.edit-toolbar .tb-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);cursor:pointer;color:var(--c-text-2);font-size:var(--fs-sm)}
|
||||
.edit-toolbar .tb-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.edit-toolbar .tb-btn.active{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.edit-body{border:1px solid var(--c-border);border-radius:0 0 var(--radius) var(--radius);padding:var(--sp-5) var(--sp-6);background:var(--c-bg-card);min-height:500px;outline:none;font-size:14px;line-height:1.8;color:var(--c-text);width:794px}
|
||||
.edit-body:focus{border-color:var(--c-primary)}
|
||||
.edit-body .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.edit-body .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.edit-body .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.edit-body table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.edit-body th,.edit-body td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.edit-body th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === left panel config === */
|
||||
.config-section{margin-bottom:var(--sp-5)}
|
||||
.config-section-title{font-size:var(--fs-sm);font-weight:var(--fw-bold);color:var(--c-text);margin-bottom:var(--sp-3);padding-bottom:var(--sp-2);border-bottom:1px solid var(--c-border-light)}
|
||||
.config-field{margin-bottom:var(--sp-3)}
|
||||
.config-field .cf-label{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-1)}
|
||||
.config-field .cf-value{font-size:var(--fs-sm);color:var(--c-text)}
|
||||
.cfg-toggle{display:flex;align-items:center;gap:var(--sp-2);cursor:pointer;user-select:none;padding:var(--sp-1) 0}
|
||||
.toggle-switch{width:32px;height:18px;border-radius:9px;background:var(--c-border);position:relative;cursor:pointer;transition:background .2s;flex-shrink:0}
|
||||
.toggle-switch.on{background:var(--c-primary)}
|
||||
.toggle-switch::after{content:'';width:14px;height:14px;border-radius:50%;background:#fff;position:absolute;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 2px rgba(0,0,0,.15)}
|
||||
.toggle-switch.on::after{transform:translateX(14px)}
|
||||
.cfg-select{padding:6px 10px;border:1px solid var(--c-border);border-radius:var(--radius-sm);font-size:var(--fs-sm);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;width:100%}
|
||||
.cfg-select:focus{border-color:var(--c-primary)}
|
||||
/* === left panel paragraph list (edit mode) === */
|
||||
.para-list-item{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);transition:all .12s;margin-bottom:2px}
|
||||
.para-list-item:hover{background:var(--c-bg-soft)}
|
||||
.para-list-item.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.para-list-item .pli-index{width:20px;height:20px;border-radius:50%;background:var(--c-bg-soft);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold);color:var(--c-text-3);flex-shrink:0}
|
||||
.para-list-item.active .pli-index{background:var(--c-primary);color:#fff}
|
||||
.para-list-item .pli-badge{font-size:9px;padding:0 6px;border-radius:6px;margin-left:auto;flex-shrink:0}
|
||||
.para-list-item .pli-badge.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.para-list-item .pli-badge.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
/* === gen progress & hints === */
|
||||
.gen-progress{background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.spinner{width:20px;height:20px;border:2px solid var(--c-border);border-top-color:var(--c-primary);border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
/* === upload zone === */
|
||||
.upload-zone{border:2px dashed var(--c-border);border-radius:var(--radius-lg);padding:var(--sp-6) var(--sp-4);text-align:center;cursor:pointer;transition:all .15s;background:var(--c-bg-card)}
|
||||
.upload-zone:hover{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.upload-icon{width:48px;height:48px;background:var(--c-bg-soft);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto var(--sp-3);color:var(--c-text-3)}
|
||||
.uploaded-file{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--c-bg-soft);border-radius:var(--radius);margin-top:var(--sp-3)}
|
||||
.uploaded-file .file-icon.excel{background:#e8f5e9;color:#2e7d32}
|
||||
/* === model card === */
|
||||
.model-card{border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-3);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.model-card .mc-icon{width:40px;height:40px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:16px;font-weight:var(--fw-bold);color:#fff}
|
||||
.model-card .mc-info{flex:1;min-width:0}
|
||||
.model-card .mc-name{font-size:var(--fs-sm);font-weight:var(--fw-medium)}
|
||||
.model-card .mc-provider{font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.model-card .mc-status{font-size:var(--fs-xs)}
|
||||
.model-card .mc-status.on{color:var(--c-success)}
|
||||
.model-card .mc-status.off{color:var(--c-text-3)}
|
||||
/* === test result modal === */
|
||||
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:1000;align-items:center;justify-content:center}
|
||||
.modal-overlay.open{display:flex}
|
||||
.modal{background:var(--c-bg-card);border-radius:var(--radius-lg);width:640px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
|
||||
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border);font-size:var(--fs-h3);font-weight:var(--fw-bold)}
|
||||
.modal-header .close-btn{cursor:pointer;color:var(--c-text-3);padding:4px;border-radius:var(--radius-sm)}
|
||||
.modal-header .close-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.modal-body{padding:var(--sp-5)}
|
||||
.modal-footer{display:flex;justify-content:flex-end;gap:var(--sp-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--c-border-light)}
|
||||
/* === typography === */
|
||||
.text-xs{font-size:var(--fs-xs)}
|
||||
.text-sm{font-size:var(--fs-sm)}
|
||||
.text-muted{color:var(--c-text-3)}
|
||||
.flex{display:flex}
|
||||
.flex-1{flex:1}
|
||||
.gap-2{gap:var(--sp-2)}
|
||||
.gap-3{gap:var(--sp-3)}
|
||||
.items-center{align-items:center}
|
||||
.justify-between{justify-content:space-between}
|
||||
.mb-3{margin-bottom:var(--sp-3)}
|
||||
.mb-4{margin-bottom:var(--sp-4)}
|
||||
.mt-3{margin-top:var(--sp-3)}
|
||||
.p-3{padding:var(--sp-3)}
|
||||
.p-4{padding:var(--sp-4)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="topbar-logo"><div class="logo-icon">A</div><span>AI 文档模板</span></div>
|
||||
<a class="topbar-tab active" href="模板管理.html"><i data-lucide="layout-dashboard" class="icon-sm"></i> 模板管理</a>
|
||||
<a class="topbar-tab" href="模型管理.html"><i data-lucide="cpu" class="icon-sm"></i> 模型管理</a>
|
||||
<a class="topbar-tab" href="执行生成.html"><i data-lucide="zap" class="icon-sm"></i> 执行生成</a>
|
||||
<a class="topbar-tab" href="生成记录.html"><i data-lucide="clock" class="icon-sm"></i> 生成记录</a>
|
||||
<div class="topbar-right">
|
||||
<span class="text-xs text-muted">v3.0</span>
|
||||
<div class="user-badge"><span class="text-xs" style="font-weight:var(--fw-medium)">周文涛</span><div class="user-avatar-sm">周</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="screen active" id="screen-template">
|
||||
<!-- Template list -->
|
||||
<div id="tplList" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||
<div class="scroll-area">
|
||||
<div class="breadcrumb"><i data-lucide="home" class="icon-sm"></i> 模板管理 / <span>所有模板</span></div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm text-muted">共 3 个模板</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="startNewTemplate()"><i data-lucide="plus"></i>新建模板</button>
|
||||
</div>
|
||||
<div class="template-grid">
|
||||
<div class="tpl-card">
|
||||
<div class="tpl-name"><i data-lucide="file-text" style="color:var(--c-primary)"></i> 季度经济活动分析报告 <span class="tpl-status ready" style="margin-left:auto">已编辑</span></div>
|
||||
<div class="tpl-desc">含经营指标、成本分析、问题总结、工作措施等 8 个段落。3 个段落需上传参考文件。</div>
|
||||
<div class="tpl-meta"><span><i data-lucide="calendar" class="icon-sm"></i> 最后编辑:2026-07-02</span><span><i data-lucide="list" class="icon-sm"></i> 段落:8</span></div>
|
||||
<div class="tpl-actions">
|
||||
<button class="btn btn-outline btn-xs" onclick="editTemplate('季度经济活动分析报告')"><i data-lucide="edit-3" class="icon-sm"></i>编辑模板</button>
|
||||
<button class="btn btn-primary btn-xs" onclick="goGenerate('季度经济活动分析报告')"><i data-lucide="zap" class="icon-sm"></i>前去生成</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tpl-card">
|
||||
<div class="tpl-name"><i data-lucide="file-text" style="color:var(--c-warning)"></i> 年度工作总结模板 <span class="tpl-status editing" style="margin-left:auto">编辑中</span></div>
|
||||
<div class="tpl-desc">含工作概况、主要成绩、存在问题、明年计划等 6 个段落。</div>
|
||||
<div class="tpl-meta"><span><i data-lucide="calendar" class="icon-sm"></i> 最后编辑:2026-06-28</span><span><i data-lucide="list" class="icon-sm"></i> 段落:6</span></div>
|
||||
<div class="tpl-actions">
|
||||
<button class="btn btn-outline btn-xs" onclick="editTemplate('年度工作总结模板')"><i data-lucide="edit-3" class="icon-sm"></i>继续编辑</button>
|
||||
<button class="btn btn-outline btn-xs"><i data-lucide="zap" class="icon-sm"></i>前去生成</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tpl-card" onclick="startNewTemplate()">
|
||||
<div class="tpl-name"><i data-lucide="file-plus" style="color:var(--c-text-3)"></i> 新建模板 <span class="tpl-status empty" style="margin-left:auto">未开始</span></div>
|
||||
<div class="tpl-desc">点击创建新模板,上传 Word 文件,配置段落。</div>
|
||||
<div class="tpl-meta"><span>—</span><span>段落:0</span></div>
|
||||
<div class="tpl-actions"><button class="btn btn-primary btn-xs" onclick="event.stopPropagation();startNewTemplate()"><i data-lucide="plus"></i>新建模板</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template editor (3-column) -->
|
||||
<div id="tplEditor" style="display:none;flex:1;overflow:hidden;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--c-border);background:var(--c-bg-card);flex-shrink:0;gap:var(--sp-3)">
|
||||
<a href="#" onclick="backToTplList()" style="font-size:var(--fs-sm);color:var(--c-primary);display:flex;align-items:center;gap:var(--sp-1)"><i data-lucide="arrow-left" class="icon-sm"></i>返回</a>
|
||||
<span style="color:var(--c-text-4)">|</span>
|
||||
<span style="font-size:var(--fs-sm);font-weight:var(--fw-medium)" id="editorTplName">季度经济活动分析报告</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn btn-primary btn-sm" onclick="alert('模板配置已保存!')"><i data-lucide="save"></i>保存模板</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex:1;overflow:hidden">
|
||||
<!-- LEFT: 段落列表 -->
|
||||
<div style="width:220px;flex-shrink:0;border-right:1px solid var(--c-border);background:var(--c-bg-card);display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--c-border-light);font-size:var(--fs-sm);font-weight:var(--fw-medium);flex-shrink:0">段落列表 <span class="text-xs text-muted" style="font-weight:400">(点击定位)</span></div>
|
||||
<div style="flex:1;overflow-y:auto;padding:var(--sp-2)" id="leftParaList"></div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: 文档预览/编辑 -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--c-bg)">
|
||||
<div style="display:flex;align-items:center;padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--c-border);background:var(--c-bg-card);flex-shrink:0;gap:var(--sp-1)">
|
||||
<span class="tb-btn active" title="加粗"><b>B</b></span><span class="tb-btn" title="斜体"><i>I</i></span><span class="tb-btn" title="下划线"><u>U</u></span>
|
||||
<span class="tb-divider"></span>
|
||||
<span class="tb-btn" title="标题"><i data-lucide="heading" class="icon-sm"></i></span>
|
||||
<span class="tb-btn" title="列表"><i data-lucide="list" class="icon-sm"></i></span>
|
||||
<span class="tb-btn" title="表格"><i data-lucide="table" class="icon-sm"></i></span>
|
||||
<span class="tb-divider"></span>
|
||||
<span class="tb-btn" title="左对齐"><i data-lucide="align-left" class="icon-sm"></i></span>
|
||||
<span class="tb-btn" title="居中"><i data-lucide="align-center" class="icon-sm"></i></span>
|
||||
<span class="tb-btn" title="右对齐"><i data-lucide="align-right" class="icon-sm"></i></span>
|
||||
<span style="margin-left:auto;font-size:var(--fs-xs);color:var(--c-text-3)" id="centerHint">点击左侧段落或文档中的段落块查看配置</span>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:var(--sp-5);display:flex;justify-content:center;align-items:flex-start" id="centerScroll">
|
||||
<div class="doc-edit-page" id="docPreviewPage">
|
||||
<div class="doc-title">2025年第一季度经济活动分析报告</div>
|
||||
<div class="doc-subtitle">某某集团有限公司</div>
|
||||
<div class="para-block" data-para="1" id="paraBlock1" onclick="selectParaFromDoc(1)">
|
||||
<span class="para-tag manual">人工编辑</span>
|
||||
<div class="sec-title" style="margin-top:0">一、主要经营指标完成情况</div>
|
||||
<p>本季度公司经营整体保持平稳,各项主要经营指标完成情况良好。营业收入完成12.35亿元,同比上升8.7%;利润总额完成1.25亿元,同比上升5.3%;净利润完成1.02亿元,同比上升4.8%。主要原因是电量增长、成本控制有效以及市场销售情况改善。</p>
|
||||
<p>从各业务板块来看,主营业务收入同比增长9.2%,得益于新增客户拓展和存量客户深度挖掘;非主营业务收入同比增长4.1%,略低于预期。</p>
|
||||
</div>
|
||||
<div class="para-block" data-para="2" id="paraBlock2" onclick="selectParaFromDoc(2)">
|
||||
<span class="para-tag ai">AI 生成 · 表格</span>
|
||||
<div class="sec-title" style="margin-top:0">主要经营指标完成情况表</div>
|
||||
<table><thead><tr><th>指标名称</th><th>本期完成值</th><th>同比增减</th><th>完成率</th><th>责任部门</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>营业收入(亿元)</td><td>12.35</td><td>+8.7%</td><td>102.3%</td><td>营销部</td></tr>
|
||||
<tr><td>利润总额(亿元)</td><td>1.25</td><td>+5.3%</td><td>100.8%</td><td>财务部</td></tr>
|
||||
<tr><td>发电量(亿度)</td><td>45.67</td><td>+6.2%</td><td>101.2%</td><td>运行部</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="para-block" data-para="3" id="paraBlock3" onclick="selectParaFromDoc(3)">
|
||||
<span class="para-tag ai">AI 生成</span>
|
||||
<div class="sec-title" style="margin-top:0">二、成本费用分析</div>
|
||||
<p>本季度营业总成本为9.87亿元,同比上升6.2%。其中燃料成本4.23亿元,占比42.9%;人工成本1.86亿元,占比18.8%;维修费用0.92亿元,占比9.3%。燃料成本同比上升7.8%,主要受煤炭市场价格波动影响。通过优化燃料采购策略和库存管理,有效控制了成本涨幅。人工成本同比上升4.5%,主要为年度调薪及新增岗位所致。维修费用同比上升3.2%,主要因部分设备进入大修周期。综合来看,成本增速低于收入增速,成本管控成效显著。</p>
|
||||
</div>
|
||||
<div class="para-block" data-para="4" id="paraBlock4" onclick="selectParaFromDoc(4)">
|
||||
<span class="para-tag ai">AI 生成</span>
|
||||
<div class="sec-title" style="margin-top:0">三、存在的主要问题</div>
|
||||
<p>一是部分指标完成质量不高,存在一定的虚增因素,需加强数据核验;二是成本控制压力仍然较大,燃料成本同比上升明显,对利润空间形成挤压;三是设备老化问题突出,非计划停运次数较去年同期增加2次,影响发电效率;四是安全风险管控仍需加强,现场违章作业问题时有发生,需进一步强化安全教育和监管力度。</p>
|
||||
</div>
|
||||
<div class="para-block" data-para="5" id="paraBlock5" onclick="selectParaFromDoc(5)">
|
||||
<span class="para-tag ai">AI 生成</span>
|
||||
<div class="sec-title" style="margin-top:0">四、下一步工作措施</div>
|
||||
<p>一是加强成本管控,深入推进降本增效专项行动,确保完成年度利润目标;二是强化设备管理,加大设备检修维护力度,提高设备可靠性,减少非计划停运;三是严格落实安全生产责任制,深入开展安全隐患排查治理,坚决杜绝较大及以上安全事故;四是加快数字化转型,推进智慧运营平台建设,提升管理效率。</p>
|
||||
</div>
|
||||
<div class="para-block" data-para="6" id="paraBlock6" onclick="selectParaFromDoc(6)">
|
||||
<span class="para-tag manual">人工编辑</span>
|
||||
<p style="margin-top:var(--sp-2)">封面信息:某某集团有限公司 · 2025年4月15日</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: 段落配置 -->
|
||||
<div style="width:380px;flex-shrink:0;border-left:1px solid var(--c-border);background:var(--c-bg-card);display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--c-border-light);font-size:var(--fs-sm);font-weight:var(--fw-medium);flex-shrink:0">
|
||||
<span id="rightPanelTitle">段落配置</span>
|
||||
<span class="text-xs text-muted" style="font-weight:400;margin-left:var(--sp-2)" id="rightParaName"></span>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:var(--sp-4)" id="rightPanelBody">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test result modal -->
|
||||
<div class="modal-overlay" id="testModal">
|
||||
<div class="modal" style="width:680px">
|
||||
<div class="modal-header">
|
||||
<span><i data-lucide="zap" class="icon-sm" style="color:var(--c-primary)"></i> 段落测试</span>
|
||||
<span class="close-btn" onclick="closeTestModal()"><i data-lucide="x" class="icon-sm"></i></span>
|
||||
</div>
|
||||
<div class="modal-body" id="testModalBody">
|
||||
<!-- Step 1: Upload file -->
|
||||
<div id="testStep1">
|
||||
<div class="config-section-title" style="border:none;padding:0;margin-bottom:var(--sp-4)">
|
||||
<span style="background:var(--c-primary);color:#fff;width:22px;height:22px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;margin-right:var(--sp-2)">1</span>
|
||||
上传附件
|
||||
</div>
|
||||
<div style="background:var(--c-bg-soft);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-3)">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i data-lucide="file-text" class="icon-sm" style="color:var(--c-primary)"></i>
|
||||
<span style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">一、主要经营指标完成情况</span>
|
||||
</div>
|
||||
<div style="border:1px dashed var(--c-border);border-radius:var(--radius);padding:var(--sp-4);text-align:center;cursor:pointer" onclick="simulateUpload()">
|
||||
<i data-lucide="upload" style="width:32px;height:32px;color:var(--c-text-3);margin-bottom:var(--sp-2)"></i>
|
||||
<div style="font-size:var(--fs-sm);color:var(--c-text-2)">点击上传参考文件(财务数据报表.xlsx)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="testUploaded" style="display:none">
|
||||
<div class="uploaded-file" style="margin-bottom:var(--sp-3);margin-top:0">
|
||||
<div class="file-icon excel" style="width:36px;height:36px"><i data-lucide="file-spreadsheet" class="icon-lg"></i></div>
|
||||
<div class="file-info"><div class="file-name">财务数据报表.xlsx</div><div class="file-size">128 KB</div></div>
|
||||
<span class="text-xs" style="color:var(--c-success)"><i data-lucide="check-circle" class="icon-sm"></i> 已上传</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" style="width:100%;justify-content:center" onclick="startTestStep2()">
|
||||
<i data-lucide="play" class="icon-sm"></i> 开始测试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Processing -->
|
||||
<div id="testStep2" style="display:none">
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4)">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="spinner"></div>
|
||||
<div style="flex:1;font-size:var(--fs-sm);color:var(--c-text-2)" id="testStepText">正在解析文件...</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--sp-2)">
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="tsk1" style="color:var(--c-text-4);font-size:var(--fs-xs)"><i data-lucide="circle" class="icon-sm"></i></span>
|
||||
<span style="font-size:var(--fs-sm);color:var(--c-text-3)">文件解析</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="tsk2" style="color:var(--c-text-4);font-size:var(--fs-xs)"><i data-lucide="circle" class="icon-sm"></i></span>
|
||||
<span style="font-size:var(--fs-sm);color:var(--c-text-3)">请求 AI 大模型(Claude 3.5 Sonnet)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="tsk3" style="color:var(--c-text-4);font-size:var(--fs-xs)"><i data-lucide="circle" class="icon-sm"></i></span>
|
||||
<span style="font-size:var(--fs-sm);color:var(--c-text-3)">生成结果填充</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Result -->
|
||||
<div id="testStep3" style="display:none">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span style="background:var(--c-success);color:#fff;padding:2px 10px;border-radius:4px;font-size:var(--fs-xs);font-weight:var(--fw-medium)">测试完成</span>
|
||||
<span class="text-xs text-muted">使用模型: Claude 3.5 Sonnet</span>
|
||||
<span class="text-xs" style="color:var(--c-success)">总耗时 4.1s</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--sp-2);margin-bottom:var(--sp-3)">
|
||||
<div class="flex items-center gap-1 text-xs text-muted"><i data-lucide="check-circle" class="icon-sm" style="color:var(--c-success)"></i> 文件解析完成</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted"><i data-lucide="check-circle" class="icon-sm" style="color:var(--c-success)"></i> AI 响应成功</div>
|
||||
</div>
|
||||
<div style="background:#f0f0ff;border-radius:var(--radius);padding:var(--sp-4);border-left:3px solid var(--c-primary);font-size:var(--fs-sm);line-height:1.8">
|
||||
<div class="text-xs" style="color:var(--c-primary);font-weight:var(--fw-medium);margin-bottom:var(--sp-2)"><i data-lucide="sparkles" class="icon-sm"></i> AI 生成结果</div>
|
||||
<p>本季度公司经营整体保持平稳,各项主要经营指标完成情况良好。营业收入完成<strong>12.35亿元</strong>,同比上升<strong>8.7%</strong>;利润总额完成<strong>1.25亿元</strong>,同比上升<strong>5.3%</strong>;净利润完成<strong>1.02亿元</strong>,同比上升<strong>4.8%</strong>。主要原因是电量增长、成本控制有效以及市场销售情况改善。</p>
|
||||
<p style="margin-top:var(--sp-2)">从各业务板块来看,主营业务收入同比增长9.2%,得益于新增客户拓展和存量客户深度挖掘;非主营业务收入同比增长4.1%,略低于预期。成本端,燃料成本同比上升6.5%,主要受煤价波动影响,但通过优化采购策略,涨幅控制在可接受范围内。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" id="testModalFooter" style="display:none">
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeTestModal()">关闭</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="alert('已确认该段落配置,保存后将影响后续正式生成')">确认配置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// === Screen switch ===
|
||||
function switchScreen(el, id) {
|
||||
// If called with a single string argument, treat it as screen id
|
||||
if(!id && typeof el === 'string') {
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
document.getElementById(el).classList.add('active');
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
// === Template list nav ===
|
||||
function backToTplList() {
|
||||
document.getElementById('tplList').style.display = 'flex';
|
||||
document.getElementById('tplEditor').style.display = 'none';
|
||||
}
|
||||
function editTemplate(name) {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = name;
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
function startNewTemplate() {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = '新建模板';
|
||||
alert('模拟:上传 Word 模板弹窗');
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
// === 3-column editor functions ===
|
||||
const paraData = [
|
||||
{id:1, name:'标题:2025年Q1报告', badge:'manual', label:'人工编辑'},
|
||||
{id:2, name:'一、主要经营指标完成情况', badge:'ai', label:'AI 生成'},
|
||||
{id:3, name:'主要经营指标完成情况表', badge:'ai', label:'AI 生成'},
|
||||
{id:4, name:'二、成本费用分析', badge:'ai', label:'AI 生成'},
|
||||
{id:5, name:'三、存在的主要问题', badge:'ai', label:'AI 生成'},
|
||||
{id:6, name:'四、下一步工作措施', badge:'ai', label:'AI 生成'},
|
||||
{id:7, name:'封面信息', badge:'manual', label:'人工编辑'},
|
||||
{id:8, name:'附图:各季度营收趋势图', badge:'ai', label:'AI 生成'},
|
||||
];
|
||||
|
||||
const paraConfigs = {
|
||||
1: {name:'一、主要经营指标完成情况', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'请根据上传的财务数据,生成经营指标完成情况章节,要求包括营业收入、利润总额等核心指标的完成值、同比变化及原因分析。', needPrompt:true, needFile:true, format:'正式报告段落', note:'需上传财务部提供的季度财务数据报表(Excel格式,含各指标完成值)'},
|
||||
2: {name:'主要经营指标完成情况表', mode:'AI 生成', model:'GPT-4o', prompt:'根据上传的各部门数据,生成指标完成情况表,包含指标名、完成值、同比、完成率、责任部门。', needPrompt:true, needFile:true, format:'表格形式', note:'需上传各部门经营数据汇总表(Excel格式,含指标明细)'},
|
||||
3: {name:'二、成本费用分析', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'根据财务数据生成成本费用分析内容,包括各项成本构成、同比变化及原因。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
4: {name:'三、存在的主要问题', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'基于经营数据和报告要求,分析当前存在的主要问题和风险。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
5: {name:'四、下一步工作措施', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'针对问题生成具体可执行的工作措施。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
6: {name:'封面信息', mode:'人工编辑', model:'—', prompt:'', needPrompt:false, needFile:false, format:'—', note:''},
|
||||
};
|
||||
|
||||
function initParaList() {
|
||||
let html = '';
|
||||
paraData.forEach(p => {
|
||||
html += '<div class="para-list-item" id="leftParaItem'+p.id+'" onclick="selectPara('+p.id+')">'
|
||||
+ '<span class="pli-index">'+p.id+'</span>'
|
||||
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+p.name+'</span>'
|
||||
+ '<span class="pli-badge '+p.badge+'">'+p.label+'</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
document.getElementById('leftParaList').innerHTML = html;
|
||||
}
|
||||
|
||||
function selectPara(id) {
|
||||
// Update left list
|
||||
document.querySelectorAll('.para-list-item').forEach(e => e.classList.remove('active'));
|
||||
const leftItem = document.getElementById('leftParaItem'+id);
|
||||
if(leftItem) leftItem.classList.add('active');
|
||||
// Scroll center to paragraph
|
||||
const block = document.getElementById('paraBlock'+id);
|
||||
if(block) {
|
||||
block.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
document.querySelectorAll('.para-block').forEach(b => b.classList.remove('selected'));
|
||||
block.classList.add('selected');
|
||||
}
|
||||
// Update right panel
|
||||
buildRightPanel(id);
|
||||
}
|
||||
|
||||
function selectParaFromDoc(id) {
|
||||
selectPara(id);
|
||||
}
|
||||
|
||||
function buildRightPanel(id) {
|
||||
const cfg = paraConfigs[id] || paraConfigs[1];
|
||||
const isAi = cfg.mode === 'AI 生成';
|
||||
document.getElementById('rightPanelTitle').textContent = '段落配置';
|
||||
document.getElementById('rightParaName').textContent = '第 '+id+' 段 · '+(cfg.name||'');
|
||||
const html = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">段落信息</div>
|
||||
<div class="config-field"><div class="cf-label">段落名称</div><div class="cf-value" style="font-weight:var(--fw-medium)">${cfg.name}</div></div>
|
||||
<div class="config-field"><div class="cf-label">所处位置</div><div class="cf-value text-sm text-muted">第 ${id} 段</div></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">生成配置</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">编辑方式</div>
|
||||
<select class="cfg-select" onchange="onEditModeChange(this,${id})">
|
||||
<option value="人工编辑" ${cfg.mode==='人工编辑'?'selected':''}>人工编辑</option>
|
||||
<option value="AI 生成" ${cfg.mode==='AI 生成'?'selected':''}>AI 生成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rmodelField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">生成模型</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.model==='Claude 3.5 Sonnet'?'selected':''}>Claude 3.5 Sonnet</option>
|
||||
<option ${cfg.model==='GPT-4o'?'selected':''}>GPT-4o</option>
|
||||
<option ${cfg.model==='DeepSeek-V3'?'selected':''}>DeepSeek-V3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rformatField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">输出格式</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.format==='正式报告段落'?'selected':''}>正式报告段落</option>
|
||||
<option ${cfg.format==='表格形式'?'selected':''}>表格形式</option>
|
||||
<option ${cfg.format==='分条列出'?'selected':''}>分条列出</option>
|
||||
<option ${cfg.format==='图表形式'?'selected':''}>图表形式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要提示词</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needPrompt?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needPrompt?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rpromptField${id}" style="${cfg.needPrompt?'':'display:none'}">
|
||||
<div class="cf-label">预设提示词 <span class="text-xs text-muted">(编辑模板时设置)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:80px;font-size:var(--fs-sm)">${cfg.prompt || '请在此输入提示词...'}</textarea>
|
||||
<div class="text-xs text-muted mt-3">提示词在模板编辑时预设,生成时可确认或微调</div>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要参考文件</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needFile?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needFile?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rnoteField${id}" style="${cfg.needFile?'':'display:none'}">
|
||||
<div class="cf-label">备注说明 <span class="text-xs text-muted">(执行生成时提示需上传什么文件)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:60px;font-size:var(--fs-sm)" placeholder="例如:需上传财务部提供的季度财务数据报表">${cfg.note || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
${isAi ? `
|
||||
<div id="rtestBtn${id}">
|
||||
<button class="btn btn-primary btn-block" style="width:100%;justify-content:center" onclick="testParagraph(${id})">
|
||||
<i data-lucide="zap" class="icon-sm"></i> 立即测试
|
||||
</button>
|
||||
<div class="text-xs text-muted" style="text-align:center;margin-top:var(--sp-2)">针对此段落单独测试 · 需上传附件 → 文件解析 → 请求AI → 填充预览</div>
|
||||
</div>
|
||||
` : `
|
||||
<div style="background:var(--c-bg-soft);padding:var(--sp-3);border-radius:var(--radius);text-align:center">
|
||||
<span class="text-xs text-muted">该段落为「人工编辑」,无需 AI 测试</span>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
document.getElementById('rightPanelBody').innerHTML = html;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function onEditModeChange(sel, paraId) {
|
||||
const isAi = sel.value === 'AI 生成';
|
||||
document.getElementById('rmodelField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rformatField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rpromptField'+paraId).style.display = 'block';
|
||||
const testBtn = document.getElementById('rtestBtn'+paraId);
|
||||
if(testBtn) testBtn.style.display = isAi ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// === Toggle switch ===
|
||||
function toggleSw(el) {
|
||||
const sw = el.querySelector('.toggle-switch');
|
||||
const txt = el.querySelector('.text-xs');
|
||||
sw.classList.toggle('on');
|
||||
if(txt) txt.textContent = sw.classList.contains('on') ? '是' : '否';
|
||||
// Show/hide note field (sibling config-field with noteField id)
|
||||
const noteField = el.closest('.config-field')?.nextElementSibling;
|
||||
if(noteField && noteField.id && noteField.id.startsWith('noteField')) {
|
||||
noteField.style.display = sw.classList.contains('on') ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// === Test paragraph - 3 step flow ===
|
||||
function testParagraph(id) {
|
||||
// Reset modal to step 1
|
||||
document.getElementById('testStep1').style.display = 'block';
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'none';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
document.getElementById('testUploaded').style.display = 'none';
|
||||
document.getElementById('testModal').classList.add('open');
|
||||
document.querySelector('#testStep1 .config-section-title span').style.background = 'var(--c-primary)';
|
||||
}
|
||||
|
||||
function simulateUpload() {
|
||||
document.querySelector('#testStep1 > div:first-child > div > div').style.display = 'none'; // hide upload box
|
||||
document.getElementById('testUploaded').style.display = 'block';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function startTestStep2() {
|
||||
document.getElementById('testStep1').style.display = 'none';
|
||||
document.getElementById('testStep2').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
// Reset task icons
|
||||
['tsk1','tsk2','tsk3'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.innerHTML = '<i data-lucide="circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-text-4)';
|
||||
});
|
||||
lucide.createIcons();
|
||||
|
||||
// Simulate step progression
|
||||
const t1 = document.getElementById('tsk1');
|
||||
const t2 = document.getElementById('tsk2');
|
||||
const t3 = document.getElementById('tsk3');
|
||||
const stepText = document.getElementById('testStepText');
|
||||
|
||||
// Step 1: File parsing (after 1.2s)
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在解析文件...';
|
||||
lucide.createIcons();
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-success)';
|
||||
// Step 2: AI request
|
||||
t2.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在请求 AI 大模型(Claude 3.5 Sonnet)...';
|
||||
lucide.createIcons();
|
||||
}, 1400);
|
||||
|
||||
setTimeout(() => {
|
||||
t2.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-success)';
|
||||
// Step 3: Fill result
|
||||
t3.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在填充生成结果...';
|
||||
lucide.createIcons();
|
||||
}, 3200);
|
||||
|
||||
setTimeout(() => {
|
||||
t3.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-success)';
|
||||
// Switch to step 3
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
}, 4800);
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
document.getElementById('testModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Model management modals ===
|
||||
function openModelModal(mode, name, provider, endpoint, apikey) {
|
||||
const title = document.getElementById('modelModalTitle');
|
||||
const mName = document.getElementById('mName');
|
||||
const mProvider = document.getElementById('mProvider');
|
||||
const mFormat = document.getElementById('mFormat');
|
||||
const mEndpoint = document.getElementById('mEndpoint');
|
||||
const mApiKey = document.getElementById('mApiKey');
|
||||
if(mode === 'add') {
|
||||
title.innerHTML = '<i data-lucide="cpu" class="icon-sm" style="color:var(--c-primary)"></i> 添加模型';
|
||||
mName.value = '';
|
||||
mProvider.value = '';
|
||||
mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = '';
|
||||
mApiKey.value = '';
|
||||
} else {
|
||||
title.innerHTML = '<i data-lucide="settings" class="icon-sm" style="color:var(--c-primary)"></i> 编辑模型 - ' + name;
|
||||
mName.value = name || '';
|
||||
mProvider.value = provider || '';
|
||||
// Guess format from provider name
|
||||
if(provider && provider.toLowerCase().includes('anthropic')) mFormat.value = 'Anthropic';
|
||||
else if(provider && (provider.toLowerCase().includes('openai') || provider.toLowerCase().includes('deepseek') || provider.toLowerCase().includes('阿里'))) mFormat.value = 'OpenAI';
|
||||
else mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = endpoint || '';
|
||||
mApiKey.value = apikey || '';
|
||||
}
|
||||
document.getElementById('modelModal').classList.add('open');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeModelModal() {
|
||||
document.getElementById('modelModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Generation ===
|
||||
function startGeneration() {
|
||||
document.getElementById('genLeftStatus').style.display = 'block';
|
||||
document.getElementById('genDoneSide').style.display = 'none';
|
||||
let pct = 0;
|
||||
const bar = document.getElementById('genProgressBar');
|
||||
const gpText = document.getElementById('genLeftText');
|
||||
const steps = [
|
||||
{id:'g1', name:'一、主要经营指标完成情况'},
|
||||
{id:'g2', name:'主要经营指标完成情况表'},
|
||||
{id:'g3', name:'二、成本费用分析'},
|
||||
{id:'g4', name:'三、存在的主要问题'},
|
||||
{id:'g5', name:'四、下一步工作措施'},
|
||||
{id:'g6', name:'附图:各季度营收趋势图'}
|
||||
];
|
||||
let currentStep = 0;
|
||||
const interval = setInterval(() => {
|
||||
pct += Math.floor(Math.random()*8)+3;
|
||||
if(pct>=100) {
|
||||
pct=100;
|
||||
clearInterval(interval);
|
||||
document.getElementById('genLeftStatus').style.display = 'none';
|
||||
document.getElementById('genDoneSide').style.display = 'flex';
|
||||
// Mark all remaining as done
|
||||
for(let i=currentStep;i<steps.length;i++) {
|
||||
const el = document.getElementById(steps[i].id);
|
||||
el.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-success)';
|
||||
}
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
bar.style.width = pct+'%';
|
||||
// Update step progress
|
||||
const stepIdx = Math.floor(pct / 17);
|
||||
if(stepIdx > currentStep && stepIdx < steps.length) {
|
||||
currentStep = stepIdx;
|
||||
// Mark previous as done
|
||||
if(currentStep > 0) {
|
||||
const prev = document.getElementById(steps[currentStep-1].id);
|
||||
if(prev) { prev.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>'; prev.style.color = 'var(--c-success)'; }
|
||||
}
|
||||
// Mark current as in progress
|
||||
const cur = document.getElementById(steps[currentStep].id);
|
||||
if(cur) { cur.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>'; cur.style.color = 'var(--c-primary)'; }
|
||||
gpText.textContent = '正在生成「'+steps[currentStep].name+'」...';
|
||||
}
|
||||
lucide.createIcons();
|
||||
},600);
|
||||
}
|
||||
|
||||
function goGenerate(name) {
|
||||
const sel = document.querySelector('#screen-generate .form-select');
|
||||
if(sel) sel.value = name;
|
||||
location.href='执行生成.html';
|
||||
}
|
||||
|
||||
function onTplSelect() {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,692 @@
|
|||
<!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>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
:root{
|
||||
--c-primary:#5b5bd6; --c-primary-hover:#4a4ac0; --c-primary-soft:#eeeefb;
|
||||
--c-primary-2:#7c7cdb; --c-primary-dark:#3b3ba8;
|
||||
--c-bg:#f5f6f8; --c-bg-card:#ffffff; --c-bg-soft:#f0f1f3; --c-bg-hover:#eaecef;
|
||||
--c-border:#e0e2e6; --c-border-light:#eaecef;
|
||||
--c-text:#1a1d24; --c-text-2:#5b626e; --c-text-3:#9aa1ad; --c-text-4:#c4c8cf;
|
||||
--c-success:#1a8c4a; --c-warning:#d48a00; --c-danger:#d33; --c-info:#4a90d9;
|
||||
--c-orange:#e68a00;
|
||||
--font:-apple-system,"PingFang SC","Microsoft YaHei","Segoe UI",Roboto,sans-serif;
|
||||
--fs-h1:22px; --fs-h2:17px; --fs-h3:15px; --fs-body:14px; --fs-sm:13px; --fs-xs:12px;
|
||||
--fw-bold:600; --fw-medium:500;
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
|
||||
--radius:8px; --radius-sm:6px; --radius-lg:12px;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-md:0 4px 12px rgba(0,0,0,.08);
|
||||
--shadow-lg:0 8px 24px rgba(0,0,0,.10);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:var(--font);font-size:var(--fs-body);color:var(--c-text);background:var(--c-bg);line-height:1.6;-webkit-font-smoothing:antialiased;height:100vh;overflow:hidden}
|
||||
a{color:var(--c-primary);text-decoration:none}
|
||||
[data-lucide]{width:16px;height:16px;stroke-width:1.8;vertical-align:middle;color:inherit;flex-shrink:0}
|
||||
.icon-sm{width:14px;height:14px}
|
||||
.icon-lg{width:20px;height:20px}
|
||||
/* === top bar === */
|
||||
.app{display:flex;flex-direction:column;height:100vh}
|
||||
.topbar{height:52px;background:var(--c-bg-card);border-bottom:1px solid var(--c-border);display:flex;align-items:center;padding:0 var(--sp-5);flex-shrink:0;gap:var(--sp-3)}
|
||||
.topbar-logo{display:flex;align-items:center;gap:var(--sp-2);font-weight:var(--fw-bold);font-size:var(--fs-h3);color:var(--c-text);margin-right:var(--sp-4);flex-shrink:0}
|
||||
.logo-icon{width:28px;height:28px;background:var(--c-primary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700}
|
||||
.topbar-nav{display:flex;align-items:center;gap:var(--sp-1)}
|
||||
.topbar-tab{padding:var(--sp-2) var(--sp-4);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);border-radius:var(--radius-sm);transition:all .12s;user-select:none;display:flex;align-items:center;gap:var(--sp-2);white-space:nowrap}
|
||||
.topbar-tab:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.topbar-tab.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:var(--sp-3)}
|
||||
.user-badge{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);background:var(--c-bg-soft);border-radius:20px;cursor:pointer}
|
||||
.user-avatar-sm{width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold)}
|
||||
/* === main layout === */
|
||||
.main-area{flex:1;overflow:hidden;display:flex}
|
||||
.screen{display:none;flex:1;overflow:hidden}
|
||||
.screen.active{display:flex}
|
||||
.scroll-area{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6)}
|
||||
/* === breadcrumb === */
|
||||
.breadcrumb{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-sm);color:var(--c-text-3);margin-bottom:var(--sp-4);flex-wrap:wrap}
|
||||
.breadcrumb span,.breadcrumb a{color:var(--c-text-2)}
|
||||
/* === card === */
|
||||
.card{background:var(--c-bg-card);border-radius:var(--radius-lg);border:1px solid var(--c-border);overflow:hidden}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.card-title{font-size:var(--fs-h3);font-weight:var(--fw-bold);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.card-body{padding:var(--sp-5)}
|
||||
/* === buttons === */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 18px;border-radius:var(--radius);font-size:var(--fs-sm);font-weight:var(--fw-medium);cursor:pointer;border:1px solid transparent;transition:all .12s;line-height:1.4;white-space:nowrap}
|
||||
.btn-primary{background:var(--c-primary);color:#fff;border-color:var(--c-primary)}
|
||||
.btn-primary:hover{background:var(--c-primary-hover)}
|
||||
.btn-outline{background:transparent;color:var(--c-text);border-color:var(--c-border)}
|
||||
.btn-outline:hover{background:var(--c-bg-soft);border-color:var(--c-text-3)}
|
||||
.btn-ghost{background:transparent;color:var(--c-text-2);border-color:transparent;padding:6px 10px}
|
||||
.btn-ghost:hover{background:var(--c-bg-soft)}
|
||||
.btn-success{background:var(--c-success);color:#fff}
|
||||
.btn-success:hover{background:#157a3e}
|
||||
.btn-sm{padding:5px 12px;font-size:var(--fs-xs)}
|
||||
.btn-xs{padding:3px 8px;font-size:11px;border-radius:var(--radius-sm)}
|
||||
/* === form === */
|
||||
.form-group{margin-bottom:var(--sp-4)}
|
||||
.form-label{display:block;font-size:var(--fs-sm);font-weight:var(--fw-medium);color:var(--c-text-2);margin-bottom:var(--sp-1)}
|
||||
.form-input,.form-select{width:100%;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;transition:border-color .15s}
|
||||
.form-input:focus,.form-select:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
.form-textarea{width:100%;min-height:80px;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);resize:vertical;outline:none}
|
||||
.form-textarea:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
/* === template list === */
|
||||
.template-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--sp-4)}
|
||||
.tpl-card{border:1px solid var(--c-border-light);border-radius:var(--radius-lg);background:var(--c-bg-card);padding:var(--sp-5);cursor:pointer;transition:all .12s}
|
||||
.tpl-card:hover{box-shadow:var(--shadow-md);border-color:var(--c-primary-2)}
|
||||
.tpl-card .tpl-name{font-size:var(--fs-body);font-weight:var(--fw-bold);margin-bottom:var(--sp-1);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.tpl-card .tpl-desc{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-3)}
|
||||
.tpl-card .tpl-meta{display:flex;align-items:center;gap:var(--sp-3);font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.tpl-card .tpl-actions{margin-top:var(--sp-3);display:flex;gap:var(--sp-2)}
|
||||
.tpl-status{font-size:10px;padding:2px 10px;border-radius:10px;display:inline-block}
|
||||
.tpl-status.ready{background:#e8f5e9;color:var(--c-success)}
|
||||
.tpl-status.editing{background:#fff3e0;color:var(--c-warning)}
|
||||
/.tpl-status.empty{background:var(--c-bg-soft);color:var(--c-text-3)}
|
||||
/* === template editor LAYOUT (left+right split) === */
|
||||
.tpl-editor{display:flex;flex:1;overflow:hidden}
|
||||
.tpl-left{width:380px;flex-shrink:0;border-right:1px solid var(--c-border);background:var(--c-bg-card);display:flex;flex-direction:column;overflow:hidden}
|
||||
.tpl-left-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.tpl-left-scroll{flex:1;overflow-y:auto;padding:var(--sp-4)}
|
||||
.tpl-right{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--c-bg)}
|
||||
.tpl-right-toolbar{display:flex;align-items:center;padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--c-border);background:var(--c-bg-card);flex-shrink:0;gap:var(--sp-2)}
|
||||
.tpl-right-body{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6);display:flex;justify-content:center}
|
||||
/* === mode toggle === */
|
||||
.mode-group{display:flex;border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;flex-shrink:0}
|
||||
.mode-btn{padding:4px 14px;cursor:pointer;font-size:var(--fs-xs);color:var(--c-text-2);background:var(--c-bg-card);border:none;border-right:1px solid var(--c-border);transition:all .12s}
|
||||
.mode-btn:last-child{border-right:none}
|
||||
.mode-btn.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.mode-btn:hover:not(.active){background:var(--c-bg-soft)}
|
||||
/* === paragraph markers === */
|
||||
.doc-edit-page{width:794px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:80px 72px 120px;min-height:500px;height:auto;font-size:14px;line-height:1.8;position:relative}
|
||||
.doc-edit-page .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.doc-edit-page .para-block{position:relative;padding:var(--sp-2);margin:var(--sp-1) 0;border-radius:var(--radius-sm);border:2px solid transparent;cursor:pointer;transition:all .12s}
|
||||
.doc-edit-page .para-block:hover{border-color:var(--c-border)}
|
||||
.doc-edit-page .para-block.selected{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.doc-edit-page .para-block .para-tag{position:absolute;right:4px;top:4px;font-size:9px;padding:1px 6px;border-radius:4px;display:none}
|
||||
.doc-edit-page .para-block:hover .para-tag,.doc-edit-page .para-block.selected .para-tag{display:block}
|
||||
.doc-edit-page .para-block .para-tag.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.doc-edit-page .para-block .para-tag.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
.doc-edit-page table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.doc-edit-page th,.doc-edit-page td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.doc-edit-page th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === edit mode rich editor === */
|
||||
.edit-toolbar{display:flex;align-items:center;gap:var(--sp-1);padding:var(--sp-2) var(--sp-3);border:1px solid var(--c-border);border-bottom:none;border-radius:var(--radius) var(--radius) 0 0;background:var(--c-bg-card);flex-wrap:wrap;flex-shrink:0}
|
||||
.edit-toolbar .tb-divider{width:1px;height:18px;background:var(--c-border);margin:0 var(--sp-1)}
|
||||
.edit-toolbar .tb-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);cursor:pointer;color:var(--c-text-2);font-size:var(--fs-sm)}
|
||||
.edit-toolbar .tb-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.edit-toolbar .tb-btn.active{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.edit-body{border:1px solid var(--c-border);border-radius:0 0 var(--radius) var(--radius);padding:var(--sp-5) var(--sp-6);background:var(--c-bg-card);min-height:500px;outline:none;font-size:14px;line-height:1.8;color:var(--c-text);width:794px}
|
||||
.edit-body:focus{border-color:var(--c-primary)}
|
||||
.edit-body .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.edit-body .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.edit-body .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.edit-body table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.edit-body th,.edit-body td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.edit-body th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === left panel config === */
|
||||
.config-section{margin-bottom:var(--sp-5)}
|
||||
.config-section-title{font-size:var(--fs-sm);font-weight:var(--fw-bold);color:var(--c-text);margin-bottom:var(--sp-3);padding-bottom:var(--sp-2);border-bottom:1px solid var(--c-border-light)}
|
||||
.config-field{margin-bottom:var(--sp-3)}
|
||||
.config-field .cf-label{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-1)}
|
||||
.config-field .cf-value{font-size:var(--fs-sm);color:var(--c-text)}
|
||||
.cfg-toggle{display:flex;align-items:center;gap:var(--sp-2);cursor:pointer;user-select:none;padding:var(--sp-1) 0}
|
||||
.toggle-switch{width:32px;height:18px;border-radius:9px;background:var(--c-border);position:relative;cursor:pointer;transition:background .2s;flex-shrink:0}
|
||||
.toggle-switch.on{background:var(--c-primary)}
|
||||
.toggle-switch::after{content:'';width:14px;height:14px;border-radius:50%;background:#fff;position:absolute;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 2px rgba(0,0,0,.15)}
|
||||
.toggle-switch.on::after{transform:translateX(14px)}
|
||||
.cfg-select{padding:6px 10px;border:1px solid var(--c-border);border-radius:var(--radius-sm);font-size:var(--fs-sm);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;width:100%}
|
||||
.cfg-select:focus{border-color:var(--c-primary)}
|
||||
/* === left panel paragraph list (edit mode) === */
|
||||
.para-list-item{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);transition:all .12s;margin-bottom:2px}
|
||||
.para-list-item:hover{background:var(--c-bg-soft)}
|
||||
.para-list-item.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.para-list-item .pli-index{width:20px;height:20px;border-radius:50%;background:var(--c-bg-soft);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold);color:var(--c-text-3);flex-shrink:0}
|
||||
.para-list-item.active .pli-index{background:var(--c-primary);color:#fff}
|
||||
.para-list-item .pli-badge{font-size:9px;padding:0 6px;border-radius:6px;margin-left:auto;flex-shrink:0}
|
||||
.para-list-item .pli-badge.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.para-list-item .pli-badge.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
/* === gen progress & hints === */
|
||||
.gen-progress{background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.spinner{width:20px;height:20px;border:2px solid var(--c-border);border-top-color:var(--c-primary);border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
/* === upload zone === */
|
||||
.upload-zone{border:2px dashed var(--c-border);border-radius:var(--radius-lg);padding:var(--sp-6) var(--sp-4);text-align:center;cursor:pointer;transition:all .15s;background:var(--c-bg-card)}
|
||||
.upload-zone:hover{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.upload-icon{width:48px;height:48px;background:var(--c-bg-soft);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto var(--sp-3);color:var(--c-text-3)}
|
||||
.uploaded-file{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--c-bg-soft);border-radius:var(--radius);margin-top:var(--sp-3)}
|
||||
.uploaded-file .file-icon.excel{background:#e8f5e9;color:#2e7d32}
|
||||
/* === model card === */
|
||||
.model-card{border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-3);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.model-card .mc-icon{width:40px;height:40px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:16px;font-weight:var(--fw-bold);color:#fff}
|
||||
.model-card .mc-info{flex:1;min-width:0}
|
||||
.model-card .mc-name{font-size:var(--fs-sm);font-weight:var(--fw-medium)}
|
||||
.model-card .mc-provider{font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.model-card .mc-status{font-size:var(--fs-xs)}
|
||||
.model-card .mc-status.on{color:var(--c-success)}
|
||||
.model-card .mc-status.off{color:var(--c-text-3)}
|
||||
/* === test result modal === */
|
||||
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:1000;align-items:center;justify-content:center}
|
||||
.modal-overlay.open{display:flex}
|
||||
.modal{background:var(--c-bg-card);border-radius:var(--radius-lg);width:640px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
|
||||
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border);font-size:var(--fs-h3);font-weight:var(--fw-bold)}
|
||||
.modal-header .close-btn{cursor:pointer;color:var(--c-text-3);padding:4px;border-radius:var(--radius-sm)}
|
||||
.modal-header .close-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.modal-body{padding:var(--sp-5)}
|
||||
.modal-footer{display:flex;justify-content:flex-end;gap:var(--sp-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--c-border-light)}
|
||||
/* === typography === */
|
||||
.text-xs{font-size:var(--fs-xs)}
|
||||
.text-sm{font-size:var(--fs-sm)}
|
||||
.text-muted{color:var(--c-text-3)}
|
||||
.flex{display:flex}
|
||||
.flex-1{flex:1}
|
||||
.gap-2{gap:var(--sp-2)}
|
||||
.gap-3{gap:var(--sp-3)}
|
||||
.items-center{align-items:center}
|
||||
.justify-between{justify-content:space-between}
|
||||
.mb-3{margin-bottom:var(--sp-3)}
|
||||
.mb-4{margin-bottom:var(--sp-4)}
|
||||
.mt-3{margin-top:var(--sp-3)}
|
||||
.p-3{padding:var(--sp-3)}
|
||||
.p-4{padding:var(--sp-4)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="topbar-logo"><div class="logo-icon">A</div><span>AI 文档模板</span></div>
|
||||
<a class="topbar-tab" href="模板管理.html"><i data-lucide="layout-dashboard" class="icon-sm"></i> 模板管理</a>
|
||||
<a class="topbar-tab" href="模型管理.html"><i data-lucide="cpu" class="icon-sm"></i> 模型管理</a>
|
||||
<a class="topbar-tab" href="执行生成.html"><i data-lucide="zap" class="icon-sm"></i> 执行生成</a>
|
||||
<a class="topbar-tab active" href="生成记录.html"><i data-lucide="clock" class="icon-sm"></i> 生成记录</a>
|
||||
<div class="topbar-right">
|
||||
<span class="text-xs text-muted">v3.0</span>
|
||||
<div class="user-badge"><span class="text-xs" style="font-weight:var(--fw-medium)">周文涛</span><div class="user-avatar-sm">周</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="screen active" id="screen-history">
|
||||
<div class="scroll-area">
|
||||
<div class="breadcrumb"><i data-lucide="home" class="icon-sm"></i> 生成记录 / <span>已生成的文件和历史</span></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-4);margin-bottom:var(--sp-4)">
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4)">
|
||||
<div style="font-size:22px;font-weight:var(--fw-bold);color:var(--c-text)">12</div>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1)">总生成次数</div>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4)">
|
||||
<div style="font-size:22px;font-weight:var(--fw-bold);color:var(--c-success)">10</div>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1)">成功</div>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4)">
|
||||
<div style="font-size:22px;font-weight:var(--fw-bold);color:var(--c-warning)">1</div>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1)">中断/取消</div>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4)">
|
||||
<div style="font-size:22px;font-weight:var(--fw-bold);color:var(--c-danger)">1</div>
|
||||
<div class="text-xs text-muted" style="margin-top:var(--sp-1)">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-3)">
|
||||
<span style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">全部生成记录</span>
|
||||
<div style="display:flex;gap:var(--sp-2)">
|
||||
<select class="cfg-select" style="width:auto;padding:4px 10px">
|
||||
<option>全部模板</option>
|
||||
<option selected>季度经济活动分析报告</option>
|
||||
<option>年度工作总结模板</option>
|
||||
</select>
|
||||
<select class="cfg-select" style="width:auto;padding:4px 10px">
|
||||
<option selected>全部状态</option>
|
||||
<option>成功</option>
|
||||
<option>失败</option>
|
||||
<option>中断</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- history list as cards -->
|
||||
<div style="display:flex;flex-direction:column;gap:var(--sp-2)">
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)">
|
||||
<div style="width:36px;height:36px;border-radius:var(--radius-sm);background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;flex-shrink:0"><i data-lucide="file-text" class="icon-sm"></i></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">2025年Q1经济活动分析报告_v3</div>
|
||||
<div style="font-size:var(--fs-xs);color:var(--c-text-3);margin-top:2px">季度经济活动分析报告 · 2026-07-02 14:35 · 6/6 段落</div>
|
||||
</div>
|
||||
<span style="font-size:10px;padding:2px 10px;border-radius:8px;background:#e8f5e9;color:var(--c-success);font-weight:var(--fw-medium);flex-shrink:0">成功</span>
|
||||
<div style="display:flex;gap:var(--sp-1);flex-shrink:0">
|
||||
<button class="btn btn-ghost btn-xs" onclick="switchScreen('screen-preview')"><i data-lucide="eye" class="icon-sm"></i>预览</button>
|
||||
<button class="btn btn-ghost btn-xs" onclick="alert('下载中...')"><i data-lucide="download" class="icon-sm"></i>下载</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)">
|
||||
<div style="width:36px;height:36px;border-radius:var(--radius-sm);background:#fff3e0;color:var(--c-warning);display:flex;align-items:center;justify-content:center;flex-shrink:0"><i data-lucide="file-text" class="icon-sm"></i></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">2025年Q1经济活动分析报告_v2</div>
|
||||
<div style="font-size:var(--fs-xs);color:var(--c-text-3);margin-top:2px">季度经济活动分析报告 · 2026-07-01 17:20 · 4/6 段落</div>
|
||||
</div>
|
||||
<span style="font-size:10px;padding:2px 10px;border-radius:8px;background:#fbe9e7;color:var(--c-danger);font-weight:var(--fw-medium);flex-shrink:0">失败</span>
|
||||
<div style="display:flex;gap:var(--sp-1);flex-shrink:0">
|
||||
<span class="text-xs text-muted">生成中断,无法预览</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)">
|
||||
<div style="width:36px;height:36px;border-radius:var(--radius-sm);background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;flex-shrink:0"><i data-lucide="file-text" class="icon-sm"></i></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">2025年Q1经济活动分析报告_v1</div>
|
||||
<div style="font-size:var(--fs-xs);color:var(--c-text-3);margin-top:2px">季度经济活动分析报告 · 2026-06-30 10:30 · 6/6 段落</div>
|
||||
</div>
|
||||
<span style="font-size:10px;padding:2px 10px;border-radius:8px;background:#e8f5e9;color:var(--c-success);font-weight:var(--fw-medium);flex-shrink:0">成功</span>
|
||||
<div style="display:flex;gap:var(--sp-1);flex-shrink:0">
|
||||
<button class="btn btn-ghost btn-xs" onclick="switchScreen('screen-preview')"><i data-lucide="eye" class="icon-sm"></i>预览</button>
|
||||
<button class="btn btn-ghost btn-xs" onclick="alert('下载中...')"><i data-lucide="download" class="icon-sm"></i>下载</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)">
|
||||
<div style="width:36px;height:36px;border-radius:var(--radius-sm);background:#e8f5e9;color:var(--c-success);display:flex;align-items:center;justify-content:center;flex-shrink:0"><i data-lucide="file-text" class="icon-sm"></i></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">年度工作总结_2026H1</div>
|
||||
<div style="font-size:var(--fs-xs);color:var(--c-text-3);margin-top:2px">年度工作总结模板 · 2026-06-28 14:30 · 5/5 段落</div>
|
||||
</div>
|
||||
<span style="font-size:10px;padding:2px 10px;border-radius:8px;background:#e8f5e9;color:var(--c-success);font-weight:var(--fw-medium);flex-shrink:0">成功</span>
|
||||
<div style="display:flex;gap:var(--sp-1);flex-shrink:0">
|
||||
<button class="btn btn-ghost btn-xs" onclick="alert('预览')"><i data-lucide="eye" class="icon-sm"></i>预览</button>
|
||||
<button class="btn btn-ghost btn-xs" onclick="alert('下载中...')"><i data-lucide="download" class="icon-sm"></i>下载</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4);opacity:.7">
|
||||
<div style="width:36px;height:36px;border-radius:var(--radius-sm);background:var(--c-bg-soft);color:var(--c-text-3);display:flex;align-items:center;justify-content:center;flex-shrink:0"><i data-lucide="file-text" class="icon-sm"></i></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--fs-sm);font-weight:var(--fw-medium);color:var(--c-text-2)">2025年Q1经济活动分析报告_初稿</div>
|
||||
<div style="font-size:var(--fs-xs);color:var(--c-text-3);margin-top:2px">季度经济活动分析报告 · 2026-06-25 09:15 · 3/6 段落</div>
|
||||
</div>
|
||||
<span style="font-size:10px;padding:2px 10px;border-radius:8px;background:#fff8e1;color:var(--c-warning);font-weight:var(--fw-medium);flex-shrink:0">中断</span>
|
||||
<span class="text-xs text-muted">用户手动取消</span>
|
||||
</div>
|
||||
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)">
|
||||
<div style="width:36px;height:36px;border-radius:var(--radius-sm);background:#e8f5e9;color:var(--c-success);display:flex;align-items:center;justify-content:center;flex-shrink:0"><i data-lucide="file-text" class="icon-sm"></i></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--fs-sm);font-weight:var(--fw-medium)">2024年度工作总结</div>
|
||||
<div style="font-size:var(--fs-xs);color:var(--c-text-3);margin-top:2px">年度工作总结模板 · 2026-06-20 16:00 · 5/5 段落</div>
|
||||
</div>
|
||||
<span style="font-size:10px;padding:2px 10px;border-radius:8px;background:#e8f5e9;color:var(--c-success);font-weight:var(--fw-medium);flex-shrink:0">成功</span>
|
||||
<div style="display:flex;gap:var(--sp-1);flex-shrink:0">
|
||||
<button class="btn btn-ghost btn-xs" onclick="alert('预览')"><i data-lucide="eye" class="icon-sm"></i>预览</button>
|
||||
<button class="btn btn-ghost btn-xs" onclick="alert('下载中...')"><i data-lucide="download" class="icon-sm"></i>下载</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// === Screen switch ===
|
||||
function switchScreen(el, id) {
|
||||
// If called with a single string argument, treat it as screen id
|
||||
if(!id && typeof el === 'string') {
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
document.getElementById(el).classList.add('active');
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
// === Template list nav ===
|
||||
function backToTplList() {
|
||||
document.getElementById('tplList').style.display = 'flex';
|
||||
document.getElementById('tplEditor').style.display = 'none';
|
||||
}
|
||||
function editTemplate(name) {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = name;
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
function startNewTemplate() {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = '新建模板';
|
||||
alert('模拟:上传 Word 模板弹窗');
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
// === 3-column editor functions ===
|
||||
const paraData = [
|
||||
{id:1, name:'标题:2025年Q1报告', badge:'manual', label:'人工编辑'},
|
||||
{id:2, name:'一、主要经营指标完成情况', badge:'ai', label:'AI 生成'},
|
||||
{id:3, name:'主要经营指标完成情况表', badge:'ai', label:'AI 生成'},
|
||||
{id:4, name:'二、成本费用分析', badge:'ai', label:'AI 生成'},
|
||||
{id:5, name:'三、存在的主要问题', badge:'ai', label:'AI 生成'},
|
||||
{id:6, name:'四、下一步工作措施', badge:'ai', label:'AI 生成'},
|
||||
{id:7, name:'封面信息', badge:'manual', label:'人工编辑'},
|
||||
{id:8, name:'附图:各季度营收趋势图', badge:'ai', label:'AI 生成'},
|
||||
];
|
||||
|
||||
const paraConfigs = {
|
||||
1: {name:'一、主要经营指标完成情况', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'请根据上传的财务数据,生成经营指标完成情况章节,要求包括营业收入、利润总额等核心指标的完成值、同比变化及原因分析。', needPrompt:true, needFile:true, format:'正式报告段落', note:'需上传财务部提供的季度财务数据报表(Excel格式,含各指标完成值)'},
|
||||
2: {name:'主要经营指标完成情况表', mode:'AI 生成', model:'GPT-4o', prompt:'根据上传的各部门数据,生成指标完成情况表,包含指标名、完成值、同比、完成率、责任部门。', needPrompt:true, needFile:true, format:'表格形式', note:'需上传各部门经营数据汇总表(Excel格式,含指标明细)'},
|
||||
3: {name:'二、成本费用分析', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'根据财务数据生成成本费用分析内容,包括各项成本构成、同比变化及原因。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
4: {name:'三、存在的主要问题', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'基于经营数据和报告要求,分析当前存在的主要问题和风险。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
5: {name:'四、下一步工作措施', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'针对问题生成具体可执行的工作措施。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
6: {name:'封面信息', mode:'人工编辑', model:'—', prompt:'', needPrompt:false, needFile:false, format:'—', note:''},
|
||||
};
|
||||
|
||||
function initParaList() {
|
||||
let html = '';
|
||||
paraData.forEach(p => {
|
||||
html += '<div class="para-list-item" id="leftParaItem'+p.id+'" onclick="selectPara('+p.id+')">'
|
||||
+ '<span class="pli-index">'+p.id+'</span>'
|
||||
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+p.name+'</span>'
|
||||
+ '<span class="pli-badge '+p.badge+'">'+p.label+'</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
document.getElementById('leftParaList').innerHTML = html;
|
||||
}
|
||||
|
||||
function selectPara(id) {
|
||||
// Update left list
|
||||
document.querySelectorAll('.para-list-item').forEach(e => e.classList.remove('active'));
|
||||
const leftItem = document.getElementById('leftParaItem'+id);
|
||||
if(leftItem) leftItem.classList.add('active');
|
||||
// Scroll center to paragraph
|
||||
const block = document.getElementById('paraBlock'+id);
|
||||
if(block) {
|
||||
block.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
document.querySelectorAll('.para-block').forEach(b => b.classList.remove('selected'));
|
||||
block.classList.add('selected');
|
||||
}
|
||||
// Update right panel
|
||||
buildRightPanel(id);
|
||||
}
|
||||
|
||||
function selectParaFromDoc(id) {
|
||||
selectPara(id);
|
||||
}
|
||||
|
||||
function buildRightPanel(id) {
|
||||
const cfg = paraConfigs[id] || paraConfigs[1];
|
||||
const isAi = cfg.mode === 'AI 生成';
|
||||
document.getElementById('rightPanelTitle').textContent = '段落配置';
|
||||
document.getElementById('rightParaName').textContent = '第 '+id+' 段 · '+(cfg.name||'');
|
||||
const html = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">段落信息</div>
|
||||
<div class="config-field"><div class="cf-label">段落名称</div><div class="cf-value" style="font-weight:var(--fw-medium)">${cfg.name}</div></div>
|
||||
<div class="config-field"><div class="cf-label">所处位置</div><div class="cf-value text-sm text-muted">第 ${id} 段</div></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">生成配置</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">编辑方式</div>
|
||||
<select class="cfg-select" onchange="onEditModeChange(this,${id})">
|
||||
<option value="人工编辑" ${cfg.mode==='人工编辑'?'selected':''}>人工编辑</option>
|
||||
<option value="AI 生成" ${cfg.mode==='AI 生成'?'selected':''}>AI 生成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rmodelField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">生成模型</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.model==='Claude 3.5 Sonnet'?'selected':''}>Claude 3.5 Sonnet</option>
|
||||
<option ${cfg.model==='GPT-4o'?'selected':''}>GPT-4o</option>
|
||||
<option ${cfg.model==='DeepSeek-V3'?'selected':''}>DeepSeek-V3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rformatField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">输出格式</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.format==='正式报告段落'?'selected':''}>正式报告段落</option>
|
||||
<option ${cfg.format==='表格形式'?'selected':''}>表格形式</option>
|
||||
<option ${cfg.format==='分条列出'?'selected':''}>分条列出</option>
|
||||
<option ${cfg.format==='图表形式'?'selected':''}>图表形式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要提示词</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needPrompt?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needPrompt?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rpromptField${id}" style="${cfg.needPrompt?'':'display:none'}">
|
||||
<div class="cf-label">预设提示词 <span class="text-xs text-muted">(编辑模板时设置)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:80px;font-size:var(--fs-sm)">${cfg.prompt || '请在此输入提示词...'}</textarea>
|
||||
<div class="text-xs text-muted mt-3">提示词在模板编辑时预设,生成时可确认或微调</div>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要参考文件</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needFile?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needFile?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rnoteField${id}" style="${cfg.needFile?'':'display:none'}">
|
||||
<div class="cf-label">备注说明 <span class="text-xs text-muted">(执行生成时提示需上传什么文件)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:60px;font-size:var(--fs-sm)" placeholder="例如:需上传财务部提供的季度财务数据报表">${cfg.note || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
${isAi ? `
|
||||
<div id="rtestBtn${id}">
|
||||
<button class="btn btn-primary btn-block" style="width:100%;justify-content:center" onclick="testParagraph(${id})">
|
||||
<i data-lucide="zap" class="icon-sm"></i> 立即测试
|
||||
</button>
|
||||
<div class="text-xs text-muted" style="text-align:center;margin-top:var(--sp-2)">针对此段落单独测试 · 需上传附件 → 文件解析 → 请求AI → 填充预览</div>
|
||||
</div>
|
||||
` : `
|
||||
<div style="background:var(--c-bg-soft);padding:var(--sp-3);border-radius:var(--radius);text-align:center">
|
||||
<span class="text-xs text-muted">该段落为「人工编辑」,无需 AI 测试</span>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
document.getElementById('rightPanelBody').innerHTML = html;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function onEditModeChange(sel, paraId) {
|
||||
const isAi = sel.value === 'AI 生成';
|
||||
document.getElementById('rmodelField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rformatField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rpromptField'+paraId).style.display = 'block';
|
||||
const testBtn = document.getElementById('rtestBtn'+paraId);
|
||||
if(testBtn) testBtn.style.display = isAi ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// === Toggle switch ===
|
||||
function toggleSw(el) {
|
||||
const sw = el.querySelector('.toggle-switch');
|
||||
const txt = el.querySelector('.text-xs');
|
||||
sw.classList.toggle('on');
|
||||
if(txt) txt.textContent = sw.classList.contains('on') ? '是' : '否';
|
||||
// Show/hide note field (sibling config-field with noteField id)
|
||||
const noteField = el.closest('.config-field')?.nextElementSibling;
|
||||
if(noteField && noteField.id && noteField.id.startsWith('noteField')) {
|
||||
noteField.style.display = sw.classList.contains('on') ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// === Test paragraph - 3 step flow ===
|
||||
function testParagraph(id) {
|
||||
// Reset modal to step 1
|
||||
document.getElementById('testStep1').style.display = 'block';
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'none';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
document.getElementById('testUploaded').style.display = 'none';
|
||||
document.getElementById('testModal').classList.add('open');
|
||||
document.querySelector('#testStep1 .config-section-title span').style.background = 'var(--c-primary)';
|
||||
}
|
||||
|
||||
function simulateUpload() {
|
||||
document.querySelector('#testStep1 > div:first-child > div > div').style.display = 'none'; // hide upload box
|
||||
document.getElementById('testUploaded').style.display = 'block';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function startTestStep2() {
|
||||
document.getElementById('testStep1').style.display = 'none';
|
||||
document.getElementById('testStep2').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
// Reset task icons
|
||||
['tsk1','tsk2','tsk3'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.innerHTML = '<i data-lucide="circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-text-4)';
|
||||
});
|
||||
lucide.createIcons();
|
||||
|
||||
// Simulate step progression
|
||||
const t1 = document.getElementById('tsk1');
|
||||
const t2 = document.getElementById('tsk2');
|
||||
const t3 = document.getElementById('tsk3');
|
||||
const stepText = document.getElementById('testStepText');
|
||||
|
||||
// Step 1: File parsing (after 1.2s)
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在解析文件...';
|
||||
lucide.createIcons();
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-success)';
|
||||
// Step 2: AI request
|
||||
t2.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在请求 AI 大模型(Claude 3.5 Sonnet)...';
|
||||
lucide.createIcons();
|
||||
}, 1400);
|
||||
|
||||
setTimeout(() => {
|
||||
t2.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-success)';
|
||||
// Step 3: Fill result
|
||||
t3.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在填充生成结果...';
|
||||
lucide.createIcons();
|
||||
}, 3200);
|
||||
|
||||
setTimeout(() => {
|
||||
t3.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-success)';
|
||||
// Switch to step 3
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
}, 4800);
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
document.getElementById('testModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Model management modals ===
|
||||
function openModelModal(mode, name, provider, endpoint, apikey) {
|
||||
const title = document.getElementById('modelModalTitle');
|
||||
const mName = document.getElementById('mName');
|
||||
const mProvider = document.getElementById('mProvider');
|
||||
const mFormat = document.getElementById('mFormat');
|
||||
const mEndpoint = document.getElementById('mEndpoint');
|
||||
const mApiKey = document.getElementById('mApiKey');
|
||||
if(mode === 'add') {
|
||||
title.innerHTML = '<i data-lucide="cpu" class="icon-sm" style="color:var(--c-primary)"></i> 添加模型';
|
||||
mName.value = '';
|
||||
mProvider.value = '';
|
||||
mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = '';
|
||||
mApiKey.value = '';
|
||||
} else {
|
||||
title.innerHTML = '<i data-lucide="settings" class="icon-sm" style="color:var(--c-primary)"></i> 编辑模型 - ' + name;
|
||||
mName.value = name || '';
|
||||
mProvider.value = provider || '';
|
||||
// Guess format from provider name
|
||||
if(provider && provider.toLowerCase().includes('anthropic')) mFormat.value = 'Anthropic';
|
||||
else if(provider && (provider.toLowerCase().includes('openai') || provider.toLowerCase().includes('deepseek') || provider.toLowerCase().includes('阿里'))) mFormat.value = 'OpenAI';
|
||||
else mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = endpoint || '';
|
||||
mApiKey.value = apikey || '';
|
||||
}
|
||||
document.getElementById('modelModal').classList.add('open');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeModelModal() {
|
||||
document.getElementById('modelModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Generation ===
|
||||
function startGeneration() {
|
||||
document.getElementById('genLeftStatus').style.display = 'block';
|
||||
document.getElementById('genDoneSide').style.display = 'none';
|
||||
let pct = 0;
|
||||
const bar = document.getElementById('genProgressBar');
|
||||
const gpText = document.getElementById('genLeftText');
|
||||
const steps = [
|
||||
{id:'g1', name:'一、主要经营指标完成情况'},
|
||||
{id:'g2', name:'主要经营指标完成情况表'},
|
||||
{id:'g3', name:'二、成本费用分析'},
|
||||
{id:'g4', name:'三、存在的主要问题'},
|
||||
{id:'g5', name:'四、下一步工作措施'},
|
||||
{id:'g6', name:'附图:各季度营收趋势图'}
|
||||
];
|
||||
let currentStep = 0;
|
||||
const interval = setInterval(() => {
|
||||
pct += Math.floor(Math.random()*8)+3;
|
||||
if(pct>=100) {
|
||||
pct=100;
|
||||
clearInterval(interval);
|
||||
document.getElementById('genLeftStatus').style.display = 'none';
|
||||
document.getElementById('genDoneSide').style.display = 'flex';
|
||||
// Mark all remaining as done
|
||||
for(let i=currentStep;i<steps.length;i++) {
|
||||
const el = document.getElementById(steps[i].id);
|
||||
el.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-success)';
|
||||
}
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
bar.style.width = pct+'%';
|
||||
// Update step progress
|
||||
const stepIdx = Math.floor(pct / 17);
|
||||
if(stepIdx > currentStep && stepIdx < steps.length) {
|
||||
currentStep = stepIdx;
|
||||
// Mark previous as done
|
||||
if(currentStep > 0) {
|
||||
const prev = document.getElementById(steps[currentStep-1].id);
|
||||
if(prev) { prev.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>'; prev.style.color = 'var(--c-success)'; }
|
||||
}
|
||||
// Mark current as in progress
|
||||
const cur = document.getElementById(steps[currentStep].id);
|
||||
if(cur) { cur.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>'; cur.style.color = 'var(--c-primary)'; }
|
||||
gpText.textContent = '正在生成「'+steps[currentStep].name+'」...';
|
||||
}
|
||||
lucide.createIcons();
|
||||
},600);
|
||||
}
|
||||
|
||||
function goGenerate(name) {
|
||||
const sel = document.querySelector('#screen-generate .form-select');
|
||||
if(sel) sel.value = name;
|
||||
location.href='执行生成.html';
|
||||
}
|
||||
|
||||
function onTplSelect() {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,648 @@
|
|||
<!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>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
:root{
|
||||
--c-primary:#5b5bd6; --c-primary-hover:#4a4ac0; --c-primary-soft:#eeeefb;
|
||||
--c-primary-2:#7c7cdb; --c-primary-dark:#3b3ba8;
|
||||
--c-bg:#f5f6f8; --c-bg-card:#ffffff; --c-bg-soft:#f0f1f3; --c-bg-hover:#eaecef;
|
||||
--c-border:#e0e2e6; --c-border-light:#eaecef;
|
||||
--c-text:#1a1d24; --c-text-2:#5b626e; --c-text-3:#9aa1ad; --c-text-4:#c4c8cf;
|
||||
--c-success:#1a8c4a; --c-warning:#d48a00; --c-danger:#d33; --c-info:#4a90d9;
|
||||
--c-orange:#e68a00;
|
||||
--font:-apple-system,"PingFang SC","Microsoft YaHei","Segoe UI",Roboto,sans-serif;
|
||||
--fs-h1:22px; --fs-h2:17px; --fs-h3:15px; --fs-body:14px; --fs-sm:13px; --fs-xs:12px;
|
||||
--fw-bold:600; --fw-medium:500;
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
|
||||
--radius:8px; --radius-sm:6px; --radius-lg:12px;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
||||
--shadow-md:0 4px 12px rgba(0,0,0,.08);
|
||||
--shadow-lg:0 8px 24px rgba(0,0,0,.10);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:var(--font);font-size:var(--fs-body);color:var(--c-text);background:var(--c-bg);line-height:1.6;-webkit-font-smoothing:antialiased;height:100vh;overflow:hidden}
|
||||
a{color:var(--c-primary);text-decoration:none}
|
||||
[data-lucide]{width:16px;height:16px;stroke-width:1.8;vertical-align:middle;color:inherit;flex-shrink:0}
|
||||
.icon-sm{width:14px;height:14px}
|
||||
.icon-lg{width:20px;height:20px}
|
||||
/* === top bar === */
|
||||
.app{display:flex;flex-direction:column;height:100vh}
|
||||
.topbar{height:52px;background:var(--c-bg-card);border-bottom:1px solid var(--c-border);display:flex;align-items:center;padding:0 var(--sp-5);flex-shrink:0;gap:var(--sp-3)}
|
||||
.topbar-logo{display:flex;align-items:center;gap:var(--sp-2);font-weight:var(--fw-bold);font-size:var(--fs-h3);color:var(--c-text);margin-right:var(--sp-4);flex-shrink:0}
|
||||
.logo-icon{width:28px;height:28px;background:var(--c-primary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700}
|
||||
.topbar-nav{display:flex;align-items:center;gap:var(--sp-1)}
|
||||
.topbar-tab{padding:var(--sp-2) var(--sp-4);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);border-radius:var(--radius-sm);transition:all .12s;user-select:none;display:flex;align-items:center;gap:var(--sp-2);white-space:nowrap}
|
||||
.topbar-tab:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.topbar-tab.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:var(--sp-3)}
|
||||
.user-badge{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);background:var(--c-bg-soft);border-radius:20px;cursor:pointer}
|
||||
.user-avatar-sm{width:22px;height:22px;border-radius:50%;background:var(--c-primary-soft);color:var(--c-primary);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold)}
|
||||
/* === main layout === */
|
||||
.main-area{flex:1;overflow:hidden;display:flex}
|
||||
.screen{display:none;flex:1;overflow:hidden}
|
||||
.screen.active{display:flex}
|
||||
.scroll-area{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6)}
|
||||
/* === breadcrumb === */
|
||||
.breadcrumb{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-sm);color:var(--c-text-3);margin-bottom:var(--sp-4);flex-wrap:wrap}
|
||||
.breadcrumb span,.breadcrumb a{color:var(--c-text-2)}
|
||||
/* === card === */
|
||||
.card{background:var(--c-bg-card);border-radius:var(--radius-lg);border:1px solid var(--c-border);overflow:hidden}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.card-title{font-size:var(--fs-h3);font-weight:var(--fw-bold);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.card-body{padding:var(--sp-5)}
|
||||
/* === buttons === */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 18px;border-radius:var(--radius);font-size:var(--fs-sm);font-weight:var(--fw-medium);cursor:pointer;border:1px solid transparent;transition:all .12s;line-height:1.4;white-space:nowrap}
|
||||
.btn-primary{background:var(--c-primary);color:#fff;border-color:var(--c-primary)}
|
||||
.btn-primary:hover{background:var(--c-primary-hover)}
|
||||
.btn-outline{background:transparent;color:var(--c-text);border-color:var(--c-border)}
|
||||
.btn-outline:hover{background:var(--c-bg-soft);border-color:var(--c-text-3)}
|
||||
.btn-ghost{background:transparent;color:var(--c-text-2);border-color:transparent;padding:6px 10px}
|
||||
.btn-ghost:hover{background:var(--c-bg-soft)}
|
||||
.btn-success{background:var(--c-success);color:#fff}
|
||||
.btn-success:hover{background:#157a3e}
|
||||
.btn-sm{padding:5px 12px;font-size:var(--fs-xs)}
|
||||
.btn-xs{padding:3px 8px;font-size:11px;border-radius:var(--radius-sm)}
|
||||
/* === form === */
|
||||
.form-group{margin-bottom:var(--sp-4)}
|
||||
.form-label{display:block;font-size:var(--fs-sm);font-weight:var(--fw-medium);color:var(--c-text-2);margin-bottom:var(--sp-1)}
|
||||
.form-input,.form-select{width:100%;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;transition:border-color .15s}
|
||||
.form-input:focus,.form-select:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
.form-textarea{width:100%;min-height:80px;padding:8px 12px;border:1px solid var(--c-border);border-radius:var(--radius);font-size:var(--fs-body);font-family:var(--font);resize:vertical;outline:none}
|
||||
.form-textarea:focus{border-color:var(--c-primary);box-shadow:0 0 0 2px var(--c-primary-soft)}
|
||||
/* === template list === */
|
||||
.template-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--sp-4)}
|
||||
.tpl-card{border:1px solid var(--c-border-light);border-radius:var(--radius-lg);background:var(--c-bg-card);padding:var(--sp-5);cursor:pointer;transition:all .12s}
|
||||
.tpl-card:hover{box-shadow:var(--shadow-md);border-color:var(--c-primary-2)}
|
||||
.tpl-card .tpl-name{font-size:var(--fs-body);font-weight:var(--fw-bold);margin-bottom:var(--sp-1);display:flex;align-items:center;gap:var(--sp-2)}
|
||||
.tpl-card .tpl-desc{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-3)}
|
||||
.tpl-card .tpl-meta{display:flex;align-items:center;gap:var(--sp-3);font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.tpl-card .tpl-actions{margin-top:var(--sp-3);display:flex;gap:var(--sp-2)}
|
||||
.tpl-status{font-size:10px;padding:2px 10px;border-radius:10px;display:inline-block}
|
||||
.tpl-status.ready{background:#e8f5e9;color:var(--c-success)}
|
||||
.tpl-status.editing{background:#fff3e0;color:var(--c-warning)}
|
||||
/.tpl-status.empty{background:var(--c-bg-soft);color:var(--c-text-3)}
|
||||
/* === template editor LAYOUT (left+right split) === */
|
||||
.tpl-editor{display:flex;flex:1;overflow:hidden}
|
||||
.tpl-left{width:380px;flex-shrink:0;border-right:1px solid var(--c-border);background:var(--c-bg-card);display:flex;flex-direction:column;overflow:hidden}
|
||||
.tpl-left-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--c-border-light);flex-shrink:0}
|
||||
.tpl-left-scroll{flex:1;overflow-y:auto;padding:var(--sp-4)}
|
||||
.tpl-right{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--c-bg)}
|
||||
.tpl-right-toolbar{display:flex;align-items:center;padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--c-border);background:var(--c-bg-card);flex-shrink:0;gap:var(--sp-2)}
|
||||
.tpl-right-body{flex:1;overflow-y:auto;padding:var(--sp-5) var(--sp-6);display:flex;justify-content:center}
|
||||
/* === mode toggle === */
|
||||
.mode-group{display:flex;border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;flex-shrink:0}
|
||||
.mode-btn{padding:4px 14px;cursor:pointer;font-size:var(--fs-xs);color:var(--c-text-2);background:var(--c-bg-card);border:none;border-right:1px solid var(--c-border);transition:all .12s}
|
||||
.mode-btn:last-child{border-right:none}
|
||||
.mode-btn.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.mode-btn:hover:not(.active){background:var(--c-bg-soft)}
|
||||
/* === paragraph markers === */
|
||||
.doc-edit-page{width:794px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:80px 72px 120px;min-height:500px;height:auto;font-size:14px;line-height:1.8;position:relative}
|
||||
.doc-edit-page .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.doc-edit-page .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.doc-edit-page .para-block{position:relative;padding:var(--sp-2);margin:var(--sp-1) 0;border-radius:var(--radius-sm);border:2px solid transparent;cursor:pointer;transition:all .12s}
|
||||
.doc-edit-page .para-block:hover{border-color:var(--c-border)}
|
||||
.doc-edit-page .para-block.selected{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.doc-edit-page .para-block .para-tag{position:absolute;right:4px;top:4px;font-size:9px;padding:1px 6px;border-radius:4px;display:none}
|
||||
.doc-edit-page .para-block:hover .para-tag,.doc-edit-page .para-block.selected .para-tag{display:block}
|
||||
.doc-edit-page .para-block .para-tag.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.doc-edit-page .para-block .para-tag.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
.doc-edit-page table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.doc-edit-page th,.doc-edit-page td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.doc-edit-page th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === edit mode rich editor === */
|
||||
.edit-toolbar{display:flex;align-items:center;gap:var(--sp-1);padding:var(--sp-2) var(--sp-3);border:1px solid var(--c-border);border-bottom:none;border-radius:var(--radius) var(--radius) 0 0;background:var(--c-bg-card);flex-wrap:wrap;flex-shrink:0}
|
||||
.edit-toolbar .tb-divider{width:1px;height:18px;background:var(--c-border);margin:0 var(--sp-1)}
|
||||
.edit-toolbar .tb-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);cursor:pointer;color:var(--c-text-2);font-size:var(--fs-sm)}
|
||||
.edit-toolbar .tb-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.edit-toolbar .tb-btn.active{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.edit-body{border:1px solid var(--c-border);border-radius:0 0 var(--radius) var(--radius);padding:var(--sp-5) var(--sp-6);background:var(--c-bg-card);min-height:500px;outline:none;font-size:14px;line-height:1.8;color:var(--c-text);width:794px}
|
||||
.edit-body:focus{border-color:var(--c-primary)}
|
||||
.edit-body .doc-title{font-size:20px;font-weight:var(--fw-bold);text-align:center;margin-bottom:var(--sp-5)}
|
||||
.edit-body .doc-subtitle{text-align:center;font-size:var(--fs-sm);color:var(--c-text-2);margin-bottom:var(--sp-5)}
|
||||
.edit-body .sec-title{font-size:16px;font-weight:var(--fw-bold);margin-top:var(--sp-5);margin-bottom:var(--sp-3);padding-bottom:var(--sp-1);border-bottom:2px solid var(--c-text)}
|
||||
.edit-body table{width:100%;border-collapse:collapse;margin:var(--sp-3) 0;font-size:var(--fs-sm)}
|
||||
.edit-body th,.edit-body td{padding:6px 10px;border:1px solid #ddd;text-align:left}
|
||||
.edit-body th{background:var(--c-bg-soft);font-weight:var(--fw-medium);font-size:var(--fs-xs)}
|
||||
/* === left panel config === */
|
||||
.config-section{margin-bottom:var(--sp-5)}
|
||||
.config-section-title{font-size:var(--fs-sm);font-weight:var(--fw-bold);color:var(--c-text);margin-bottom:var(--sp-3);padding-bottom:var(--sp-2);border-bottom:1px solid var(--c-border-light)}
|
||||
.config-field{margin-bottom:var(--sp-3)}
|
||||
.config-field .cf-label{font-size:var(--fs-xs);color:var(--c-text-3);margin-bottom:var(--sp-1)}
|
||||
.config-field .cf-value{font-size:var(--fs-sm);color:var(--c-text)}
|
||||
.cfg-toggle{display:flex;align-items:center;gap:var(--sp-2);cursor:pointer;user-select:none;padding:var(--sp-1) 0}
|
||||
.toggle-switch{width:32px;height:18px;border-radius:9px;background:var(--c-border);position:relative;cursor:pointer;transition:background .2s;flex-shrink:0}
|
||||
.toggle-switch.on{background:var(--c-primary)}
|
||||
.toggle-switch::after{content:'';width:14px;height:14px;border-radius:50%;background:#fff;position:absolute;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 2px rgba(0,0,0,.15)}
|
||||
.toggle-switch.on::after{transform:translateX(14px)}
|
||||
.cfg-select{padding:6px 10px;border:1px solid var(--c-border);border-radius:var(--radius-sm);font-size:var(--fs-sm);font-family:var(--font);color:var(--c-text);background:var(--c-bg-card);outline:none;width:100%}
|
||||
.cfg-select:focus{border-color:var(--c-primary)}
|
||||
/* === left panel paragraph list (edit mode) === */
|
||||
.para-list-item{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--fs-sm);color:var(--c-text-2);transition:all .12s;margin-bottom:2px}
|
||||
.para-list-item:hover{background:var(--c-bg-soft)}
|
||||
.para-list-item.active{background:var(--c-primary-soft);color:var(--c-primary);font-weight:var(--fw-medium)}
|
||||
.para-list-item .pli-index{width:20px;height:20px;border-radius:50%;background:var(--c-bg-soft);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:var(--fw-bold);color:var(--c-text-3);flex-shrink:0}
|
||||
.para-list-item.active .pli-index{background:var(--c-primary);color:#fff}
|
||||
.para-list-item .pli-badge{font-size:9px;padding:0 6px;border-radius:6px;margin-left:auto;flex-shrink:0}
|
||||
.para-list-item .pli-badge.ai{background:var(--c-primary-soft);color:var(--c-primary)}
|
||||
.para-list-item .pli-badge.manual{background:#fff3e0;color:var(--c-orange)}
|
||||
/* === gen progress & hints === */
|
||||
.gen-progress{background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-4);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.spinner{width:20px;height:20px;border:2px solid var(--c-border);border-top-color:var(--c-primary);border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
/* === upload zone === */
|
||||
.upload-zone{border:2px dashed var(--c-border);border-radius:var(--radius-lg);padding:var(--sp-6) var(--sp-4);text-align:center;cursor:pointer;transition:all .15s;background:var(--c-bg-card)}
|
||||
.upload-zone:hover{border-color:var(--c-primary);background:var(--c-primary-soft)}
|
||||
.upload-icon{width:48px;height:48px;background:var(--c-bg-soft);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto var(--sp-3);color:var(--c-text-3)}
|
||||
.uploaded-file{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--c-bg-soft);border-radius:var(--radius);margin-top:var(--sp-3)}
|
||||
.uploaded-file .file-icon.excel{background:#e8f5e9;color:#2e7d32}
|
||||
/* === model card === */
|
||||
.model-card{border:1px solid var(--c-border-light);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-3);display:flex;align-items:center;gap:var(--sp-4)}
|
||||
.model-card .mc-icon{width:40px;height:40px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:16px;font-weight:var(--fw-bold);color:#fff}
|
||||
.model-card .mc-info{flex:1;min-width:0}
|
||||
.model-card .mc-name{font-size:var(--fs-sm);font-weight:var(--fw-medium)}
|
||||
.model-card .mc-provider{font-size:var(--fs-xs);color:var(--c-text-3)}
|
||||
.model-card .mc-status{font-size:var(--fs-xs)}
|
||||
.model-card .mc-status.on{color:var(--c-success)}
|
||||
.model-card .mc-status.off{color:var(--c-text-3)}
|
||||
/* === test result modal === */
|
||||
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:1000;align-items:center;justify-content:center}
|
||||
.modal-overlay.open{display:flex}
|
||||
.modal{background:var(--c-bg-card);border-radius:var(--radius-lg);width:640px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
|
||||
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--c-border);font-size:var(--fs-h3);font-weight:var(--fw-bold)}
|
||||
.modal-header .close-btn{cursor:pointer;color:var(--c-text-3);padding:4px;border-radius:var(--radius-sm)}
|
||||
.modal-header .close-btn:hover{background:var(--c-bg-soft);color:var(--c-text)}
|
||||
.modal-body{padding:var(--sp-5)}
|
||||
.modal-footer{display:flex;justify-content:flex-end;gap:var(--sp-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--c-border-light)}
|
||||
/* === typography === */
|
||||
.text-xs{font-size:var(--fs-xs)}
|
||||
.text-sm{font-size:var(--fs-sm)}
|
||||
.text-muted{color:var(--c-text-3)}
|
||||
.flex{display:flex}
|
||||
.flex-1{flex:1}
|
||||
.gap-2{gap:var(--sp-2)}
|
||||
.gap-3{gap:var(--sp-3)}
|
||||
.items-center{align-items:center}
|
||||
.justify-between{justify-content:space-between}
|
||||
.mb-3{margin-bottom:var(--sp-3)}
|
||||
.mb-4{margin-bottom:var(--sp-4)}
|
||||
.mt-3{margin-top:var(--sp-3)}
|
||||
.p-3{padding:var(--sp-3)}
|
||||
.p-4{padding:var(--sp-4)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="topbar-logo"><div class="logo-icon">A</div><span>AI 文档模板</span></div>
|
||||
<a class="topbar-tab" href="模板管理.html"><i data-lucide="layout-dashboard" class="icon-sm"></i> 模板管理</a>
|
||||
<a class="topbar-tab" href="模型管理.html"><i data-lucide="cpu" class="icon-sm"></i> 模型管理</a>
|
||||
<a class="topbar-tab" href="执行生成.html"><i data-lucide="zap" class="icon-sm"></i> 执行生成</a>
|
||||
<a class="topbar-tab" href="生成记录.html"><i data-lucide="clock" class="icon-sm"></i> 生成记录</a>
|
||||
<div class="topbar-right">
|
||||
<span class="text-xs text-muted">v3.0</span>
|
||||
<div class="user-badge"><span class="text-xs" style="font-weight:var(--fw-medium)">周文涛</span><div class="user-avatar-sm">周</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="screen active" id="screen-preview">
|
||||
<div class="scroll-area" style="padding-bottom:0">
|
||||
<div class="breadcrumb"><i data-lucide="home" class="icon-sm"></i> 预览编辑 / <span>在线查看 · 手动编辑 · 导出</span></div>
|
||||
<div style="display:flex;gap:var(--sp-5);align-items:flex-start">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title"><i data-lucide="pen-tool" style="color:var(--c-primary)"></i>文档编辑 — 季度经济活动分析报告</span>
|
||||
<div style="display:flex;gap:var(--sp-2)">
|
||||
<button class="btn btn-ghost btn-sm"><i data-lucide="undo-2" class="icon-sm"></i>撤销</button>
|
||||
<button class="btn btn-ghost btn-sm"><i data-lucide="redo-2" class="icon-sm"></i>重做</button>
|
||||
<button class="btn btn-success btn-sm" onclick="alert('导出中...')"><i data-lucide="download"></i>导出</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-toolbar">
|
||||
<span class="tb-btn active"><b>B</b></span><span class="tb-btn"><i>I</i></span><span class="tb-btn"><u>U</u></span>
|
||||
<span class="tb-divider"></span>
|
||||
<span class="tb-btn"><i data-lucide="heading" class="icon-sm"></i></span>
|
||||
<span class="tb-btn"><i data-lucide="list" class="icon-sm"></i></span>
|
||||
<span class="tb-btn"><i data-lucide="list-ordered" class="icon-sm"></i></span>
|
||||
<span class="tb-btn"><i data-lucide="table" class="icon-sm"></i></span>
|
||||
<span class="tb-divider"></span>
|
||||
<span class="tb-btn"><i data-lucide="align-left" class="icon-sm"></i></span>
|
||||
<span class="tb-btn"><i data-lucide="align-center" class="icon-sm"></i></span>
|
||||
<span class="tb-btn"><i data-lucide="align-right" class="icon-sm"></i></span>
|
||||
<span class="tb-divider"></span>
|
||||
<span class="tb-btn"><i data-lucide="image" class="icon-sm"></i></span>
|
||||
<span class="tb-btn"><i data-lucide="link" class="icon-sm"></i></span>
|
||||
</div>
|
||||
<div class="edit-body" contenteditable="true" style="border-top:none;border-radius:0 0 var(--radius) var(--radius)">
|
||||
<div class="doc-title">2025年第一季度经济活动分析报告</div>
|
||||
<div class="doc-subtitle">某某集团有限公司</div>
|
||||
<div class="sec-title">一、主要经营指标完成情况 <span class="text-xs" style="color:var(--c-primary);font-weight:400">AI 生成 · Claude 3.5</span></div>
|
||||
<div style="background:#f0f0ff;padding:var(--sp-3);border-radius:var(--radius-sm);border-left:3px solid var(--c-primary);margin:var(--sp-2) 0">
|
||||
<div class="text-xs" style="color:var(--c-primary);font-weight:var(--fw-medium);margin-bottom:var(--sp-1)"><i data-lucide="sparkles" class="icon-sm"></i> AI 生成内容</div>
|
||||
<p>本季度营业收入完成<strong>12.35亿元</strong>,同比上升<strong>8.7%</strong>;利润总额<strong>1.25亿元</strong>,同比上升<strong>5.3%</strong>;净利润<strong>1.02亿元</strong>,同比上升<strong>4.8%</strong>。主要原因是电量增长、成本控制有效以及市场销售情况改善。</p>
|
||||
</div>
|
||||
<div class="sec-title">主要经营指标完成情况表 <span class="text-xs" style="color:var(--c-primary);font-weight:400">AI 生成 · GPT-4o</span></div>
|
||||
<table><thead><tr><th>指标名称</th><th>完成值</th><th>同比</th><th>完成率</th><th>责任部门</th></tr></thead><tbody>
|
||||
<tr><td>营业收入(亿元)</td><td>12.35</td><td style="color:var(--c-success)">+8.7%</td><td>102.3%</td><td>营销部</td></tr>
|
||||
<tr><td>利润总额(亿元)</td><td>1.25</td><td style="color:var(--c-success)">+5.3%</td><td>100.8%</td><td>财务部</td></tr>
|
||||
<tr><td>发电量(亿度)</td><td>45.67</td><td style="color:var(--c-success)">+6.2%</td><td>101.2%</td><td>运行部</td></tr>
|
||||
</tbody></table>
|
||||
<div class="sec-title">二、成本费用分析 <span class="text-xs" style="color:var(--c-primary);font-weight:400">AI 生成 · Claude 3.5</span></div>
|
||||
<p>本季度营业总成本为9.87亿元,同比上升6.2%。其中燃料成本4.23亿元,占比42.9%;人工成本1.86亿元,占比18.8%。</p>
|
||||
<div class="sec-title">三、存在的主要问题 <span class="text-xs" style="color:var(--c-primary);font-weight:400">AI 生成 · DeepSeek-V3</span></div>
|
||||
<p>一是部分指标完成质量不高;二是成本控制压力较大;三是设备老化问题突出。</p>
|
||||
<div class="sec-title">四、下一步工作措施 <span class="text-xs" style="color:var(--c-primary);font-weight:400">AI 生成 · Claude 3.5</span></div>
|
||||
<p>一是加强成本管控;二是强化设备管理;三是落实安全生产责任制。</p>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:var(--sp-2) var(--sp-4);border-top:1px solid var(--c-border-light);background:var(--c-bg-soft);border-radius:0 0 var(--radius) var(--radius)">
|
||||
<span class="text-xs text-muted">字数:1,023 字 · 最后保存:刚刚</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline btn-xs" onclick="alert('已保存')"><i data-lucide="save" class="icon-sm"></i>保存</button>
|
||||
<button class="btn btn-primary btn-xs" onclick="alert('导出 .docx')"><i data-lucide="download" class="icon-sm"></i>导出Word</button>
|
||||
<button class="btn btn-success btn-xs" onclick="alert('导出 .pdf')"><i data-lucide="file" class="icon-sm"></i>导出PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// === Screen switch ===
|
||||
function switchScreen(el, id) {
|
||||
// If called with a single string argument, treat it as screen id
|
||||
if(!id && typeof el === 'string') {
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
document.getElementById(el).classList.add('active');
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.topbar-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
// === Template list nav ===
|
||||
function backToTplList() {
|
||||
document.getElementById('tplList').style.display = 'flex';
|
||||
document.getElementById('tplEditor').style.display = 'none';
|
||||
}
|
||||
function editTemplate(name) {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = name;
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
function startNewTemplate() {
|
||||
document.getElementById('tplList').style.display = 'none';
|
||||
document.getElementById('tplEditor').style.display = 'flex';
|
||||
document.getElementById('editorTplName').textContent = '新建模板';
|
||||
alert('模拟:上传 Word 模板弹窗');
|
||||
initParaList();
|
||||
selectPara(1);
|
||||
}
|
||||
|
||||
// === 3-column editor functions ===
|
||||
const paraData = [
|
||||
{id:1, name:'标题:2025年Q1报告', badge:'manual', label:'人工编辑'},
|
||||
{id:2, name:'一、主要经营指标完成情况', badge:'ai', label:'AI 生成'},
|
||||
{id:3, name:'主要经营指标完成情况表', badge:'ai', label:'AI 生成'},
|
||||
{id:4, name:'二、成本费用分析', badge:'ai', label:'AI 生成'},
|
||||
{id:5, name:'三、存在的主要问题', badge:'ai', label:'AI 生成'},
|
||||
{id:6, name:'四、下一步工作措施', badge:'ai', label:'AI 生成'},
|
||||
{id:7, name:'封面信息', badge:'manual', label:'人工编辑'},
|
||||
{id:8, name:'附图:各季度营收趋势图', badge:'ai', label:'AI 生成'},
|
||||
];
|
||||
|
||||
const paraConfigs = {
|
||||
1: {name:'一、主要经营指标完成情况', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'请根据上传的财务数据,生成经营指标完成情况章节,要求包括营业收入、利润总额等核心指标的完成值、同比变化及原因分析。', needPrompt:true, needFile:true, format:'正式报告段落', note:'需上传财务部提供的季度财务数据报表(Excel格式,含各指标完成值)'},
|
||||
2: {name:'主要经营指标完成情况表', mode:'AI 生成', model:'GPT-4o', prompt:'根据上传的各部门数据,生成指标完成情况表,包含指标名、完成值、同比、完成率、责任部门。', needPrompt:true, needFile:true, format:'表格形式', note:'需上传各部门经营数据汇总表(Excel格式,含指标明细)'},
|
||||
3: {name:'二、成本费用分析', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'根据财务数据生成成本费用分析内容,包括各项成本构成、同比变化及原因。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
4: {name:'三、存在的主要问题', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'基于经营数据和报告要求,分析当前存在的主要问题和风险。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
5: {name:'四、下一步工作措施', mode:'AI 生成', model:'Claude 3.5 Sonnet', prompt:'针对问题生成具体可执行的工作措施。', needPrompt:true, needFile:false, format:'正式报告段落', note:''},
|
||||
6: {name:'封面信息', mode:'人工编辑', model:'—', prompt:'', needPrompt:false, needFile:false, format:'—', note:''},
|
||||
};
|
||||
|
||||
function initParaList() {
|
||||
let html = '';
|
||||
paraData.forEach(p => {
|
||||
html += '<div class="para-list-item" id="leftParaItem'+p.id+'" onclick="selectPara('+p.id+')">'
|
||||
+ '<span class="pli-index">'+p.id+'</span>'
|
||||
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+p.name+'</span>'
|
||||
+ '<span class="pli-badge '+p.badge+'">'+p.label+'</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
document.getElementById('leftParaList').innerHTML = html;
|
||||
}
|
||||
|
||||
function selectPara(id) {
|
||||
// Update left list
|
||||
document.querySelectorAll('.para-list-item').forEach(e => e.classList.remove('active'));
|
||||
const leftItem = document.getElementById('leftParaItem'+id);
|
||||
if(leftItem) leftItem.classList.add('active');
|
||||
// Scroll center to paragraph
|
||||
const block = document.getElementById('paraBlock'+id);
|
||||
if(block) {
|
||||
block.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
document.querySelectorAll('.para-block').forEach(b => b.classList.remove('selected'));
|
||||
block.classList.add('selected');
|
||||
}
|
||||
// Update right panel
|
||||
buildRightPanel(id);
|
||||
}
|
||||
|
||||
function selectParaFromDoc(id) {
|
||||
selectPara(id);
|
||||
}
|
||||
|
||||
function buildRightPanel(id) {
|
||||
const cfg = paraConfigs[id] || paraConfigs[1];
|
||||
const isAi = cfg.mode === 'AI 生成';
|
||||
document.getElementById('rightPanelTitle').textContent = '段落配置';
|
||||
document.getElementById('rightParaName').textContent = '第 '+id+' 段 · '+(cfg.name||'');
|
||||
const html = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">段落信息</div>
|
||||
<div class="config-field"><div class="cf-label">段落名称</div><div class="cf-value" style="font-weight:var(--fw-medium)">${cfg.name}</div></div>
|
||||
<div class="config-field"><div class="cf-label">所处位置</div><div class="cf-value text-sm text-muted">第 ${id} 段</div></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">生成配置</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">编辑方式</div>
|
||||
<select class="cfg-select" onchange="onEditModeChange(this,${id})">
|
||||
<option value="人工编辑" ${cfg.mode==='人工编辑'?'selected':''}>人工编辑</option>
|
||||
<option value="AI 生成" ${cfg.mode==='AI 生成'?'selected':''}>AI 生成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rmodelField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">生成模型</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.model==='Claude 3.5 Sonnet'?'selected':''}>Claude 3.5 Sonnet</option>
|
||||
<option ${cfg.model==='GPT-4o'?'selected':''}>GPT-4o</option>
|
||||
<option ${cfg.model==='DeepSeek-V3'?'selected':''}>DeepSeek-V3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" id="rformatField${id}" style="${isAi?'':'display:none'}">
|
||||
<div class="cf-label">输出格式</div>
|
||||
<select class="cfg-select">
|
||||
<option ${cfg.format==='正式报告段落'?'selected':''}>正式报告段落</option>
|
||||
<option ${cfg.format==='表格形式'?'selected':''}>表格形式</option>
|
||||
<option ${cfg.format==='分条列出'?'selected':''}>分条列出</option>
|
||||
<option ${cfg.format==='图表形式'?'selected':''}>图表形式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要提示词</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needPrompt?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needPrompt?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rpromptField${id}" style="${cfg.needPrompt?'':'display:none'}">
|
||||
<div class="cf-label">预设提示词 <span class="text-xs text-muted">(编辑模板时设置)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:80px;font-size:var(--fs-sm)">${cfg.prompt || '请在此输入提示词...'}</textarea>
|
||||
<div class="text-xs text-muted mt-3">提示词在模板编辑时预设,生成时可确认或微调</div>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<div class="cf-label">是否需要参考文件</div>
|
||||
<div class="cfg-toggle" onclick="toggleSw(this)">
|
||||
<div class="toggle-switch ${cfg.needFile?'on':''}"></div>
|
||||
<span class="text-xs">${cfg.needFile?'是':'否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" id="rnoteField${id}" style="${cfg.needFile?'':'display:none'}">
|
||||
<div class="cf-label">备注说明 <span class="text-xs text-muted">(执行生成时提示需上传什么文件)</span></div>
|
||||
<textarea class="form-textarea" style="min-height:60px;font-size:var(--fs-sm)" placeholder="例如:需上传财务部提供的季度财务数据报表">${cfg.note || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
${isAi ? `
|
||||
<div id="rtestBtn${id}">
|
||||
<button class="btn btn-primary btn-block" style="width:100%;justify-content:center" onclick="testParagraph(${id})">
|
||||
<i data-lucide="zap" class="icon-sm"></i> 立即测试
|
||||
</button>
|
||||
<div class="text-xs text-muted" style="text-align:center;margin-top:var(--sp-2)">针对此段落单独测试 · 需上传附件 → 文件解析 → 请求AI → 填充预览</div>
|
||||
</div>
|
||||
` : `
|
||||
<div style="background:var(--c-bg-soft);padding:var(--sp-3);border-radius:var(--radius);text-align:center">
|
||||
<span class="text-xs text-muted">该段落为「人工编辑」,无需 AI 测试</span>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
document.getElementById('rightPanelBody').innerHTML = html;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function onEditModeChange(sel, paraId) {
|
||||
const isAi = sel.value === 'AI 生成';
|
||||
document.getElementById('rmodelField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rformatField'+paraId).style.display = isAi ? 'block' : 'none';
|
||||
document.getElementById('rpromptField'+paraId).style.display = 'block';
|
||||
const testBtn = document.getElementById('rtestBtn'+paraId);
|
||||
if(testBtn) testBtn.style.display = isAi ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// === Toggle switch ===
|
||||
function toggleSw(el) {
|
||||
const sw = el.querySelector('.toggle-switch');
|
||||
const txt = el.querySelector('.text-xs');
|
||||
sw.classList.toggle('on');
|
||||
if(txt) txt.textContent = sw.classList.contains('on') ? '是' : '否';
|
||||
// Show/hide note field (sibling config-field with noteField id)
|
||||
const noteField = el.closest('.config-field')?.nextElementSibling;
|
||||
if(noteField && noteField.id && noteField.id.startsWith('noteField')) {
|
||||
noteField.style.display = sw.classList.contains('on') ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// === Test paragraph - 3 step flow ===
|
||||
function testParagraph(id) {
|
||||
// Reset modal to step 1
|
||||
document.getElementById('testStep1').style.display = 'block';
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'none';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
document.getElementById('testUploaded').style.display = 'none';
|
||||
document.getElementById('testModal').classList.add('open');
|
||||
document.querySelector('#testStep1 .config-section-title span').style.background = 'var(--c-primary)';
|
||||
}
|
||||
|
||||
function simulateUpload() {
|
||||
document.querySelector('#testStep1 > div:first-child > div > div').style.display = 'none'; // hide upload box
|
||||
document.getElementById('testUploaded').style.display = 'block';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function startTestStep2() {
|
||||
document.getElementById('testStep1').style.display = 'none';
|
||||
document.getElementById('testStep2').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'none';
|
||||
// Reset task icons
|
||||
['tsk1','tsk2','tsk3'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.innerHTML = '<i data-lucide="circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-text-4)';
|
||||
});
|
||||
lucide.createIcons();
|
||||
|
||||
// Simulate step progression
|
||||
const t1 = document.getElementById('tsk1');
|
||||
const t2 = document.getElementById('tsk2');
|
||||
const t3 = document.getElementById('tsk3');
|
||||
const stepText = document.getElementById('testStepText');
|
||||
|
||||
// Step 1: File parsing (after 1.2s)
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在解析文件...';
|
||||
lucide.createIcons();
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
t1.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t1.style.color = 'var(--c-success)';
|
||||
// Step 2: AI request
|
||||
t2.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在请求 AI 大模型(Claude 3.5 Sonnet)...';
|
||||
lucide.createIcons();
|
||||
}, 1400);
|
||||
|
||||
setTimeout(() => {
|
||||
t2.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t2.style.color = 'var(--c-success)';
|
||||
// Step 3: Fill result
|
||||
t3.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-primary)';
|
||||
stepText.textContent = '正在填充生成结果...';
|
||||
lucide.createIcons();
|
||||
}, 3200);
|
||||
|
||||
setTimeout(() => {
|
||||
t3.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
t3.style.color = 'var(--c-success)';
|
||||
// Switch to step 3
|
||||
document.getElementById('testStep2').style.display = 'none';
|
||||
document.getElementById('testStep3').style.display = 'block';
|
||||
document.getElementById('testModalFooter').style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
}, 4800);
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
document.getElementById('testModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Model management modals ===
|
||||
function openModelModal(mode, name, provider, endpoint, apikey) {
|
||||
const title = document.getElementById('modelModalTitle');
|
||||
const mName = document.getElementById('mName');
|
||||
const mProvider = document.getElementById('mProvider');
|
||||
const mFormat = document.getElementById('mFormat');
|
||||
const mEndpoint = document.getElementById('mEndpoint');
|
||||
const mApiKey = document.getElementById('mApiKey');
|
||||
if(mode === 'add') {
|
||||
title.innerHTML = '<i data-lucide="cpu" class="icon-sm" style="color:var(--c-primary)"></i> 添加模型';
|
||||
mName.value = '';
|
||||
mProvider.value = '';
|
||||
mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = '';
|
||||
mApiKey.value = '';
|
||||
} else {
|
||||
title.innerHTML = '<i data-lucide="settings" class="icon-sm" style="color:var(--c-primary)"></i> 编辑模型 - ' + name;
|
||||
mName.value = name || '';
|
||||
mProvider.value = provider || '';
|
||||
// Guess format from provider name
|
||||
if(provider && provider.toLowerCase().includes('anthropic')) mFormat.value = 'Anthropic';
|
||||
else if(provider && (provider.toLowerCase().includes('openai') || provider.toLowerCase().includes('deepseek') || provider.toLowerCase().includes('阿里'))) mFormat.value = 'OpenAI';
|
||||
else mFormat.value = 'OpenAI';
|
||||
mEndpoint.value = endpoint || '';
|
||||
mApiKey.value = apikey || '';
|
||||
}
|
||||
document.getElementById('modelModal').classList.add('open');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeModelModal() {
|
||||
document.getElementById('modelModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// === Generation ===
|
||||
function startGeneration() {
|
||||
document.getElementById('genLeftStatus').style.display = 'block';
|
||||
document.getElementById('genDoneSide').style.display = 'none';
|
||||
let pct = 0;
|
||||
const bar = document.getElementById('genProgressBar');
|
||||
const gpText = document.getElementById('genLeftText');
|
||||
const steps = [
|
||||
{id:'g1', name:'一、主要经营指标完成情况'},
|
||||
{id:'g2', name:'主要经营指标完成情况表'},
|
||||
{id:'g3', name:'二、成本费用分析'},
|
||||
{id:'g4', name:'三、存在的主要问题'},
|
||||
{id:'g5', name:'四、下一步工作措施'},
|
||||
{id:'g6', name:'附图:各季度营收趋势图'}
|
||||
];
|
||||
let currentStep = 0;
|
||||
const interval = setInterval(() => {
|
||||
pct += Math.floor(Math.random()*8)+3;
|
||||
if(pct>=100) {
|
||||
pct=100;
|
||||
clearInterval(interval);
|
||||
document.getElementById('genLeftStatus').style.display = 'none';
|
||||
document.getElementById('genDoneSide').style.display = 'flex';
|
||||
// Mark all remaining as done
|
||||
for(let i=currentStep;i<steps.length;i++) {
|
||||
const el = document.getElementById(steps[i].id);
|
||||
el.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>';
|
||||
el.style.color = 'var(--c-success)';
|
||||
}
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
bar.style.width = pct+'%';
|
||||
// Update step progress
|
||||
const stepIdx = Math.floor(pct / 17);
|
||||
if(stepIdx > currentStep && stepIdx < steps.length) {
|
||||
currentStep = stepIdx;
|
||||
// Mark previous as done
|
||||
if(currentStep > 0) {
|
||||
const prev = document.getElementById(steps[currentStep-1].id);
|
||||
if(prev) { prev.innerHTML = '<i data-lucide="check-circle" class="icon-sm"></i>'; prev.style.color = 'var(--c-success)'; }
|
||||
}
|
||||
// Mark current as in progress
|
||||
const cur = document.getElementById(steps[currentStep].id);
|
||||
if(cur) { cur.innerHTML = '<i data-lucide="loader" class="icon-sm"></i>'; cur.style.color = 'var(--c-primary)'; }
|
||||
gpText.textContent = '正在生成「'+steps[currentStep].name+'」...';
|
||||
}
|
||||
lucide.createIcons();
|
||||
},600);
|
||||
}
|
||||
|
||||
function goGenerate(name) {
|
||||
const sel = document.querySelector('#screen-generate .form-select');
|
||||
if(sel) sel.value = name;
|
||||
location.href='执行生成.html';
|
||||
}
|
||||
|
||||
function onTplSelect() {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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 亿元
|
||||
```
|
||||
|
|
@ -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.x,strict 模式
|
||||
- 文件命名: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 字段管理
|
||||
|
|
@ -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 命令)
|
||||
|
||||
### 路由与 API(3 天)
|
||||
- [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,116 @@
|
|||
# AI 文档模板生成系统 · 后续迭代任务拆解清单
|
||||
|
||||
基线版本:`a796301`
|
||||
|
||||
目标:把当前系统从“按 Heading 解析的模板配置工具”迭代为“占位 + 块级编辑 + 精确写回”的文档生成系统。
|
||||
|
||||
总工期估算:4-6 周(两人并行:前端 + 后端)
|
||||
|
||||
## 第一阶段:模板标准化与块级编辑(第 1-2 周)
|
||||
|
||||
### 模板占位体系(3-4 天)
|
||||
- [ ] 定义统一占位命名规范(标题、日期、摘要、正文、表格)
|
||||
- [ ] 支持封面字段占位(如 `{{ report_date }}`、`{{ report_company_name }}`)
|
||||
- [ ] 支持标题字段占位(如 `{{ report_period_title }}`)
|
||||
- [ ] 支持摘要块占位(如 `{{ opening_summary }}`)
|
||||
- [ ] 支持正文块占位(如 `{{ section_xxx }}`)
|
||||
- [ ] 输出模板占位说明文档
|
||||
|
||||
### 模板解析器升级(3-5 天)
|
||||
- [ ] 同时识别 Heading、显式占位、固定文本块
|
||||
- [ ] 识别封面区域与正文区域
|
||||
- [ ] 识别未设置 Heading 的疑似二级小节
|
||||
- [ ] 为每个块记录原始锚点与块类型
|
||||
- [ ] 输出新的块级结构 JSON
|
||||
|
||||
### 块级模板编辑器(5-7 天)
|
||||
- [ ] 支持固定块 / AI 块 / 标题块 / 摘要块显示
|
||||
- [ ] 支持新增块
|
||||
- [ ] 支持删除块
|
||||
- [ ] 支持块上移 / 下移
|
||||
- [ ] 支持块间插入 AI 块
|
||||
- [ ] 支持块内容手动编辑
|
||||
- [ ] 支持块写入方式配置(仅标题 / 插入 / 替换)
|
||||
- [ ] 支持保存块级模板结构
|
||||
|
||||
## 第二阶段:导出精确写回与模板体检(第 2-3 周)
|
||||
|
||||
### Word 写回引擎升级(5-7 天)
|
||||
- [ ] 标题单独替换
|
||||
- [ ] 日期单独替换
|
||||
- [ ] 摘要块单独替换
|
||||
- [ ] AI 块按位置精确写回
|
||||
- [ ] 固定块完全保留
|
||||
- [ ] 同一锚点下多块顺序写回
|
||||
- [ ] 表格块精确填充
|
||||
- [ ] 混合块(文字 + 表格)顺序写回
|
||||
|
||||
### 模板体检工具(3-4 天)
|
||||
- [ ] 检查未设置 Heading 的小节
|
||||
- [ ] 检查缺失占位的可变字段
|
||||
- [ ] 检查会被并入上一级的正文块
|
||||
- [ ] 检查重复锚点与歧义标题
|
||||
- [ ] 给出修复建议列表
|
||||
- [ ] 在模板上传后展示体检结果
|
||||
|
||||
### 模板修复辅助(2-3 天)
|
||||
- [ ] 一键将疑似标题转为独立块
|
||||
- [ ] 一键将日期识别为变量占位
|
||||
- [ ] 一键将开篇摘要识别为 AI 摘要块
|
||||
- [ ] 一键补充推荐占位名
|
||||
|
||||
## 第三阶段:生成体验与结果编辑(第 3-4 周)
|
||||
|
||||
### 执行生成体验优化(2-3 天)
|
||||
- [ ] 按段落记忆最近使用文件
|
||||
- [ ] 历史文件默认折叠 + 已选数量提示
|
||||
- [ ] 已选文件去重与快速复用
|
||||
- [ ] AI 段落与人工段落展示区分更明显
|
||||
|
||||
### 生成编排增强(3-4 天)
|
||||
- [ ] 多块并发生成调度
|
||||
- [ ] 按块重试与失败回退
|
||||
- [ ] 按块取消 / 跳过生成
|
||||
- [ ] 开篇摘要优先生成或最后汇总生成
|
||||
|
||||
### 预览编辑页增强(4-5 天)
|
||||
- [ ] 支持按块查看生成结果
|
||||
- [ ] 支持单块重新生成
|
||||
- [ ] 支持恢复模板默认内容
|
||||
- [ ] 支持标记固定块 / AI 块来源
|
||||
- [ ] 支持导出前最终校对
|
||||
|
||||
## 第四阶段:模型能力与运维增强(第 4-5 周)
|
||||
|
||||
### 模型管理增强(2-3 天)
|
||||
- [ ] 常用厂商预设(DeepSeek / OpenAI / Anthropic / 自定义)
|
||||
- [ ] 常用模型名预设
|
||||
- [ ] 不同厂商默认 API 地址模板
|
||||
- [ ] 测试结果摘要化展示
|
||||
- [ ] DeepSeek 余额缓存与刷新时间展示
|
||||
|
||||
### AI 服务增强(3-4 天)
|
||||
- [ ] 提示词模板分层(系统 / 模板 / 块 / 文件)
|
||||
- [ ] 文件摘要生成增强(Excel / PDF / Word)
|
||||
- [ ] 多模型回退策略
|
||||
- [ ] 单块超时与重试策略优化
|
||||
- [ ] 生成日志与错误诊断信息增强
|
||||
|
||||
## 第五阶段:质量与交付(第 5-6 周)
|
||||
|
||||
- [ ] 真实模板专项联调(公司经营分析报告、财务分析报告等)
|
||||
- [ ] 多级报告模板兼容性测试
|
||||
- [ ] 未规范模板的降级策略验证
|
||||
- [ ] 导出结果与原 Word 样式一致性验收
|
||||
- [ ] 错误提示与异常恢复完善
|
||||
- [ ] 使用手册与模板制作指南整理
|
||||
|
||||
## 阶段交付物
|
||||
|
||||
| 阶段 | 交付物 |
|
||||
|------|--------|
|
||||
| 第 1-2 周 | 块级模板结构、占位规范、可编辑模板画布 |
|
||||
| 第 2-3 周 | 精确写回导出引擎、模板体检与修复辅助 |
|
||||
| 第 3-4 周 | 完整生成与预览编辑闭环 |
|
||||
| 第 4-5 周 | 模型管理增强、AI 调用稳定性提升 |
|
||||
| 第 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
@ -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`,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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`),
|
||||
}
|
||||
|
|
@ -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}`),
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
declare module "*.vue" { import type { DefineComponent } from "vue"; const comp: DefineComponent<{}, {}, any>; export default comp; }
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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" }]
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"compilerOptions": { "composite": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true },
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue