按原型图重构核心页面并完善生成链路

This commit is contained in:
zwt13703
2026-07-02 16:09:34 +08:00
parent 5a43cc70d5
commit a8716aa3c6
10 changed files with 1228 additions and 210 deletions
+1
View File
@@ -16,6 +16,7 @@
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式 - 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容 - 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
- 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果 - 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果
- 生成过程中支持 SSE 进度推送与取消生成
- 查看生成记录与预览页真实结果 - 查看生成记录与预览页真实结果
- 导出 Word:基于原模板替换标题下内容并生成可下载文件 - 导出 Word:基于原模板替换标题下内容并生成可下载文件
+34 -89
View File
@@ -1,5 +1,4 @@
import json import json
import time
import os import os
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -7,6 +6,7 @@ from datetime import datetime
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse
from config import settings from config import settings
from database import get_db from database import get_db
@@ -17,6 +17,13 @@ from models.paragraph import Paragraph
from models.template import Template from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
from services.ai_service import call_ai from services.ai_service import call_ai
from services.generation_runtime import (
build_mock_content,
generation_progress,
request_cancel,
run_generation,
update_progress,
)
from services.minio_client import upload_bytes from services.minio_client import upload_bytes
router = APIRouter() router = APIRouter()
@@ -36,47 +43,6 @@ def _serialize_document(document: Document) -> dict:
"updated_at": document.updated_at, "updated_at": document.updated_at,
} }
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(db: AsyncSession, paragraph: Paragraph) -> AiModel | None:
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()
@router.post("/test") @router.post("/test")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)): async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id) paragraph = await db.get(Paragraph, body.paragraph_id)
@@ -90,7 +56,7 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge
model = await db.get(AiModel, paragraph.model_id) model = await db.get(AiModel, paragraph.model_id)
if model is None or model.status != "enabled": if model is None or model.status != "enabled":
content = _build_mock_content(paragraph) content = build_mock_content(paragraph)
message = "当前未找到可用模型,返回本地模拟生成结果。" message = "当前未找到可用模型,返回本地模拟生成结果。"
else: else:
result = await call_ai(paragraph, model) result = await call_ai(paragraph, model)
@@ -162,54 +128,32 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
) )
db.add(document) db.add(document)
await db.flush() await db.flush()
done_count = 0
failed_count = 0
for paragraph in paragraphs:
if paragraph.edit_mode == "manual":
content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]}
status = "success"
duration = 0
error_message = ""
else:
start = time.perf_counter()
model = await _get_effective_model(db, paragraph)
try:
if model is None:
content = _build_mock_content(paragraph)
else:
result = await call_ai(paragraph, model)
content = result.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=paragraph.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
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}"
await db.commit() await db.commit()
await db.refresh(document) 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)) 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") @router.get("/documents")
async def list_documents( async def list_documents(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -263,9 +207,10 @@ async def cancel_document(document_id: int, db: AsyncSession = Depends(get_db)):
if document is None: if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在") raise HTTPException(status_code=404, detail="生成记录不存在")
document.status = "cancelled" if document.status in {"completed", "failed", "cancelled"}:
await db.commit() return Response(data=_serialize_document(document))
await db.refresh(document)
request_cancel(document_id)
return Response(data=_serialize_document(document)) return Response(data=_serialize_document(document))
+12 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import json import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from urllib.parse import urlparse
import httpx import httpx
@@ -60,6 +61,10 @@ def _build_prompt(paragraph: Paragraph) -> tuple[str, str]:
def _normalize_openai_endpoint(api_endpoint: str) -> str: def _normalize_openai_endpoint(api_endpoint: str) -> str:
endpoint = api_endpoint.rstrip("/") 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"): if endpoint.endswith("/chat/completions"):
return endpoint return endpoint
if endpoint.endswith("/v1"): if endpoint.endswith("/v1"):
@@ -88,7 +93,7 @@ async def _post_with_retry(
response = await client.post(url, headers=headers, json=payload) response = await client.post(url, headers=headers, json=payload)
if response.status_code in (429, 500, 502, 503, 504): if response.status_code in (429, 500, 502, 503, 504):
raise httpx.HTTPStatusError( raise httpx.HTTPStatusError(
f"上游模型响应异常: {response.status_code}", f"上游模型响应异常: {response.status_code} - {response.text[:500]}",
request=response.request, request=response.request,
response=response, response=response,
) )
@@ -99,7 +104,10 @@ async def _post_with_retry(
if attempt == settings.AI_MAX_RETRIES - 1: if attempt == settings.AI_MAX_RETRIES - 1:
break break
await asyncio.sleep(2 ** attempt) await asyncio.sleep(2 ** attempt)
raise RuntimeError(f"模型调用失败:{last_error}") 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: async def _call_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
@@ -116,7 +124,7 @@ async def _call_openai_compatible(model: AiModel, system_prompt: str, user_promp
"temperature": 0.3, "temperature": 0.3,
} }
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client: 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) response = await _post_with_retry(client, _normalize_openai_endpoint(model.api_endpoint), headers, payload)
body = response.json() body = response.json()
text = body["choices"][0]["message"]["content"] text = body["choices"][0]["message"]["content"]
@@ -139,7 +147,7 @@ async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str)
"anthropic-version": "2023-06-01", "anthropic-version": "2023-06-01",
"content-type": "application/json", "content-type": "application/json",
} }
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client: 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) response = await _post_with_retry(client, _normalize_anthropic_endpoint(model.api_endpoint), headers, payload)
body = response.json() body = response.json()
text = "" text = ""
+174
View File
@@ -0,0 +1,174 @@
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
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)
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:
if model is None:
content = build_mock_content(paragraph)
else:
result_data = await call_ai(paragraph, model)
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)
+44
View File
@@ -50,3 +50,47 @@
6. 更新 README 与任务拆解清单,标记真实模型调用与文件上传相关能力的完成状态。 6. 更新 README 与任务拆解清单,标记真实模型调用与文件上传相关能力的完成状态。
7. 再次执行后端语法检查与前端类型检查,确认本轮改动稳定。 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. 执行前端类型检查,确认本轮页面重构稳定。
- **执行结果**: 现有三大主页面已开始按原型稿收口,整体信息层级和布局结构明显向原型靠齐,同时保留了当前已完成的真实业务能力。
@@ -33,11 +33,11 @@
- [ ] 文件摘要生成(Excel 解析 + 数据统计) - [ ] 文件摘要生成(Excel 解析 + 数据统计)
### 文档生成器(3 天) ### 文档生成器(3 天)
- [ ] 单段落生成流程 - [x] 单段落生成流程
- [ ] 多段落并行生成编排(asyncio.gather - [ ] 多段落并行生成编排(asyncio.gather
- [ ] SSE 进度推送 - [x] SSE 进度推送
- [ ] 取消生成支持 - [x] 取消生成支持
- [ ] 生成结果入库 - [x] 生成结果入库
### Word 导出引擎(5-7 天) ### Word 导出引擎(5-7 天)
- [ ] 复制原模板文件作为骨架 - [ ] 复制原模板文件作为骨架
@@ -50,7 +50,7 @@
### 路由与 API3 天) ### 路由与 API3 天)
- [x] 模板 CRUD 路由 - [x] 模板 CRUD 路由
- [x] 模型 CRUD 路由 - [x] 模型 CRUD 路由
- [ ] 生成相关路由(测试/全量/进度SSE/取消) - [x] 生成相关路由(测试/全量/进度SSE/取消)
- [ ] 导出路由(Word/PDF - [ ] 导出路由(Word/PDF
- [x] 文件上传/管理 - [x] 文件上传/管理
+156 -35
View File
@@ -1,51 +1,172 @@
<template> <template>
<a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }"> <a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }">
<a-layout style="min-height:100vh"> <div class="app-shell">
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible theme="light" :width="220"> <header class="topbar">
<div class="logo">{{ collapsed ? "A" : "AI 文档模板" }}</div> <div class="topbar-logo">
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="onMenuClick"> <div class="logo-icon">A</div>
<a-menu-item key="/templates"><folder-outlined /> 模板管理</a-menu-item> <span>AI 文档模板</span>
<a-menu-item key="/models"><api-outlined /> 模型管理</a-menu-item> </div>
<a-menu-item key="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item> <nav class="topbar-nav">
</a-menu> <button :class="['topbar-tab', { active: isActive('/templates') }]" @click="router.push('/templates')">
</a-layout-sider> <folder-outlined />
<a-layout> 模板管理
<a-layout-header style="background:#fff;padding:0 24px;display:flex;align-items:center;border-bottom:1px solid #f0f0f0"> </button>
<menu-unfold-outlined v-if="collapsed" @click="collapsed=false" style="font-size:18px;cursor:pointer" /> <button :class="['topbar-tab', { active: isActive('/models') }]" @click="router.push('/models')">
<menu-fold-outlined v-else @click="collapsed=true" style="font-size:18px;cursor:pointer" /> <api-outlined />
<div style="flex:1" /> 模型管理
<a-badge :count="0" :overflow-count="99"><bell-outlined style="font-size:18px;color:#999" /></a-badge> </button>
<span style="margin-left:16px;font-size:13px;color:#666">周文涛</span> <button :class="['topbar-tab', { active: isActive('/generate') }]" @click="router.push('/generate')">
</a-layout-header> <thunderbolt-outlined />
<a-layout-content style="margin:0;background:#f5f6f8;overflow:auto"> 执行生成
</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 /> <router-view />
</a-layout-content> </main>
</a-layout> </div>
</a-layout>
</a-config-provider> </a-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router' import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
import {
FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined,
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
} from '@ant-design/icons-vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const collapsed = ref(false)
const selectedKeys = ref([route.path])
watch(() => route.path, p => { selectedKeys.value = [p] }) function isActive(path: string) {
return route.path.startsWith(path)
function onMenuClick(info: any) {
router.push(info.key)
} }
</script> </script>
<style scoped> <style scoped>
.logo { height: 52px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: #5b5bd6; border-bottom: 1px solid #f0f0f0; } .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> </style>
+440 -27
View File
@@ -1,29 +1,442 @@
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f: File)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small" :loading="uploadingMap[p.id]">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template> <template>
<div 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 v-if="generating" class="status-block">
<div class="gen-progress">
<div class="spinner" />
<div class="gen-text">{{ progressText }}</div>
</div>
<div class="progress-card">
<div class="progress-title">生成进度</div>
<a-progress :percent="progress" />
<a-button danger block @click="cancelGen">取消生成</a-button>
</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>
<span>{{ paragraph.title }}</span>
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div>
<div v-if="paragraph.need_file" class="file-info">
<a-upload :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id] ? '已上传' : '上传文件' }}</a-button>
</a-upload>
<span v-if="uploadedFiles[paragraph.id]" class="uploaded-name">{{ uploadedFiles[paragraph.id] }}</span>
</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"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from "vue-router"; import { useRoute, useRouter } from 'vue-router'
import { useTemplateStore } from "@/stores/template"; import { message } from 'ant-design-vue'
import { useDocumentStore } from "@/stores/document"; import { useTemplateStore } from '@/stores/template'
import { generateApi } from "@/api/generate"; import { useDocumentStore } from '@/stores/document'
import { message } from "ant-design-vue"; import { generateApi } from '@/api/generate'
const router = useRouter();
const tplStore = useTemplateStore(); const route = useRoute()
const docStore = useDocumentStore(); const router = useRouter()
const templates = ref<any[]>([]); const tplStore = useTemplateStore()
const paragraphs = ref<any[]>([]); const docStore = useDocumentStore()
const selectedTplId = ref(undefined);
const uploadedFiles = ref<Record<number,string>>({}); const templates = ref<any[]>([])
const uploadedFilePaths = ref<Record<number,string>>({}); const paragraphs = ref<any[]>([])
const uploadingMap = ref<Record<number,boolean>>({}); const selectedTplId = ref<number | undefined>(undefined)
const generating = ref(false); const uploadedFiles = ref<Record<number, string>>({})
const progress = ref(0); const uploadedFilePaths = ref<Record<number, string>>({})
const progressText = ref(""); const uploadingMap = ref<Record<number, boolean>>({})
const tplInfo = ref<any>({}); const generating = ref(false)
const needFileCount = computed(()=>paragraphs.value.filter(p=>p.need_file).length); const progress = ref(0)
const fileCount = computed(()=>Object.keys(uploadedFiles.value).length); const progressText = ref('')
onMounted(async()=>{await tplStore.fetchList();templates.value=tplStore.templates as any}); const tplInfo = ref<any>({})
async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);paragraphs.value=tplStore.paragraphs as any;tplInfo.value={paragraph_count:tpl.paragraph_count,fileCount:paragraphs.value.filter((p:any)=>p.need_file).length};uploadedFiles.value={};uploadedFilePaths.value={};} const currentDocumentId = ref<number | null>(null)
async function handleFileUpload(paraId:number,file:File){try{uploadingMap.value[paraId]=true;const fd=new FormData();fd.append("file",file);const res:any=await generateApi.upload(fd);uploadedFiles.value[paraId]=res.data.file_name;uploadedFilePaths.value[paraId]=res.data.file_path;message.success("文件上传成功")}catch(e:any){message.error(e.message||"文件上传失败")}finally{uploadingMap.value[paraId]=false}return false} let progressSource: EventSource | null = null
async function startGen(){if(!selectedTplId.value){message.warning("请先选择模板");return}const missing=paragraphs.value.filter((p:any)=>p.need_file&&!uploadedFilePaths.value[p.id]);if(missing.length){message.warning("还有必传文件未上传");return}generating.value=true;progress.value=30;progressText.value="正在生成...";const fileMap=Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([k,v])=>[String(k),v]));const data={template_id:selectedTplId.value,file_map:fileMap};try{const doc=await docStore.generateFull(data);progress.value=100;progressText.value="生成完成";message.success("生成完成");router.push(`/preview/${doc.id}`)}catch(e:any){message.error(e.message||"生成失败")}finally{generating.value=false}}
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.keys(uploadedFiles.value).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
async function refreshTemplates() {
await tplStore.fetchList()
templates.value = tplStore.templates as any
}
function closeProgressSource() {
if (progressSource) {
progressSource.close()
progressSource = null
}
}
function bindProgress(documentId: number) {
closeProgressSource()
progressSource = new EventSource(generateApi.progress(documentId))
progressSource.addEventListener('progress', (event: MessageEvent) => {
const payload = JSON.parse(event.data)
progress.value = payload.percent || 0
progressText.value = payload.message || '正在生成...'
if (payload.status === 'completed' || payload.status === 'failed') {
generating.value = false
closeProgressSource()
message.success(payload.status === 'completed' ? '生成完成' : '生成结束,部分段落已回退为模拟结果')
router.push(`/preview/${documentId}`)
} else if (payload.status === 'cancelled') {
generating.value = false
closeProgressSource()
message.info('已取消生成')
}
})
progressSource.onerror = () => {
closeProgressSource()
}
}
onMounted(async () => {
await refreshTemplates()
const queryId = Number(route.query.templateId)
if (queryId) {
selectedTplId.value = queryId
await onTplChange(queryId)
}
})
onBeforeUnmount(() => {
closeProgressSource()
})
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 handleFileUpload(paragraphId: number, file: File) {
try {
uploadingMap.value[paragraphId] = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
uploadedFiles.value[paragraphId] = res.data.file_name
uploadedFilePaths.value[paragraphId] = res.data.file_path
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploadingMap.value[paragraphId] = false
}
return false
}
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
progress.value = 0
progressText.value = '任务创建中...'
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 })
currentDocumentId.value = document.id
bindProgress(document.id)
} catch (error: any) {
generating.value = false
message.error(error.message || '生成失败')
}
}
async function cancelGen() {
if (!currentDocumentId.value) return
await docStore.cancel(currentDocumentId.value)
progressText.value = '正在取消...'
}
</script> </script>
<style scoped>.para-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid #f0f0f0;border-radius:8px;margin-bottom:8px}.para-row.needFile{background:#fff;border-color:#d9d9d9}.para-row.noFile{background:#fafafa;border-style:dashed;opacity:.7}.para-info{display:flex;align-items:center;gap:8px}.para-info .idx{width:22px;height:22px;border-radius:50%;background:#f0f0ff;color:#5b5bd6;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.no-file-tag{font-size:12px;color:#999}</style>
<style scoped>
.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: center;
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;
margin-left: 8px;
}
.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>
+128 -33
View File
@@ -1,33 +1,40 @@
<template> <template>
<div style="padding:24px"> <div class="page-wrap">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"> <div class="breadcrumb">
<h2>模型管理</h2> <span>模型管理</span>
<a-button type="primary" @click="openAdd">添加模型</a-button> <span>/</span>
<span>AI 模型配置</span>
</div> </div>
<a-row :gutter="[16, 16]"> <div class="panel-card">
<a-col :span="8" v-for="item in models" :key="item.id"> <div class="panel-head">
<a-card :title="item.name"> <div class="panel-title">可用模型列表</div>
<template #extra> <a-button @click="openAdd">添加模型</a-button>
<a-button type="link" size="small" @click="openEdit(item)">编辑</a-button> </div>
</template> <div class="panel-body">
<p>厂商{{ item.provider }}</p> <div class="model-card" v-for="item in models" :key="item.id">
<p>格式{{ item.api_format }}</p> <div class="mc-icon">{{ item.name?.slice(0, 1)?.toUpperCase() || 'M' }}</div>
<p>地址{{ item.api_endpoint }}</p> <div class="mc-info">
<p>密钥{{ item.api_key_preview || '未设置' }}</p> <div class="mc-name">{{ item.name }}</div>
<p>状态<a-switch :checked="item.status === 'enabled'" @change="toggleStatus(item)" /></p> <div class="mc-provider">{{ item.provider }} · {{ item.api_endpoint }}</div>
<div style="margin-top:12px;display:flex;gap:8px"> <div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
<a-button size="small" @click="runTest(item)">连接测试</a-button> </div>
<a-button danger size="small" @click="removeModel(item.id)">删除</a-button> <div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
</div>
<div class="mc-actions">
<a-button size="small" @click="runTest(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> </div>
</a-card>
</a-col>
</a-row>
<a-modal v-model:open="modalOpen" :title="isEdit ? '编辑模型' : '添加模型'" @ok="saveModel"> <a-modal v-model:open="modalOpen" :title="isEdit ? '编辑模型' : '添加模型'" @ok="saveModel">
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="模型名称"> <a-form-item label="模型名称">
<a-input v-model:value="form.name" placeholder="如 gpt-4o-mini / deepseek-chat / claude-3-5-sonnet-latest" /> <a-input v-model:value="form.name" />
</a-form-item> </a-form-item>
<a-form-item label="供应厂商"> <a-form-item label="供应厂商">
<a-input v-model:value="form.provider" /> <a-input v-model:value="form.provider" />
@@ -39,10 +46,10 @@
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="API 地址"> <a-form-item label="API 地址">
<a-input v-model:value="form.api_endpoint" placeholder="https://api.openai.com 或兼容网关地址" /> <a-input v-model:value="form.api_endpoint" />
</a-form-item> </a-form-item>
<a-form-item label="API Key"> <a-form-item label="API Key">
<a-input-password v-model:value="form.api_key" placeholder="编辑时留空表示保持原密钥不变" /> <a-input-password v-model:value="form.api_key" />
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@@ -101,8 +108,7 @@ async function saveModel() {
} }
async function toggleStatus(item: any) { async function toggleStatus(item: any) {
const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled' await store.update(item.id, { status: item.status === 'enabled' ? 'disabled' : 'enabled' })
await store.update(item.id, { status: nextStatus })
await refreshList() await refreshList()
message.success('状态已更新') message.success('状态已更新')
} }
@@ -112,17 +118,106 @@ async function runTest(item: any) {
const result: any = await store.test(item.id) const result: any = await store.test(item.id)
Modal.info({ Modal.info({
title: '连接测试结果', title: '连接测试结果',
width: 640, width: 680,
content: JSON.stringify(result.data, null, 2), content: JSON.stringify(result.data, null, 2),
}) })
} catch (error: any) { } catch (error: any) {
message.error(error.message || '连接测试失败') message.error(error.message || '连接测试失败')
} }
} }
async function removeModel(id: number) {
await store.remove(id)
await refreshList()
message.success('模型已删除')
}
</script> </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>
+232 -15
View File
@@ -1,17 +1,234 @@
<template><div style="padding:24px"><h2>模板管理</h2><a-button type="primary" @click="handleUpload">新建模板</a-button><a-table :dataSource="templates" :columns="columns" rowKey="id" style="margin-top:16px" /><a-modal v-model:open="uploadOpen" title="上传模板" @ok="doUpload"><a-upload-dragger :beforeUpload="beforeUpload"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击或拖拽 Word 模板文件到此区域</p></a-upload-dragger></a-modal></div></template> <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"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { onMounted, ref } from 'vue'
import { useRouter } from "vue-router"; import { useRouter } from 'vue-router'
import { useTemplateStore } from "@/stores/template"; import { message } from 'ant-design-vue'
import { FileAddOutlined } from "@ant-design/icons-vue"; import { FileAddOutlined, FileTextOutlined, PlusSquareOutlined } from '@ant-design/icons-vue'
const router = useRouter(); import { useTemplateStore } from '@/stores/template'
const store = useTemplateStore();
const templates = ref([]); const router = useRouter()
const uploadOpen = ref(false); const store = useTemplateStore()
const uploadFile = ref<File | null>(null); const templates = ref<any[]>([])
const columns = [{title:"名称",dataIndex:"name"},{title:"段落",dataIndex:"paragraph_count"},{title:"状态",dataIndex:"status"},{title:"操作",key:"action"}]; const uploadOpen = ref(false)
onMounted(async()=>{await store.fetchList();templates.value=store.templates as any}); const uploadFile = ref<File | null>(null)
function handleUpload(){uploadOpen.value=true}
function beforeUpload(file:File){uploadFile.value=file;return false} async function refreshList() {
async function doUpload(){if(!uploadFile.value)return;await store.upload(uploadFile.value);uploadOpen.value=false;router.push(`/templates/${store.currentTemplate?.id}/edit`)} 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> </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>