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

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 以加密形式存储,前端仅显示脱敏内容
- 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果
- 生成过程中支持 SSE 进度推送与取消生成
- 查看生成记录与预览页真实结果
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
+34 -89
View File
@@ -1,5 +1,4 @@
import json
import time
import os
import uuid
from datetime import datetime
@@ -7,6 +6,7 @@ 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
@@ -17,6 +17,13 @@ from models.paragraph import Paragraph
from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
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
router = APIRouter()
@@ -36,47 +43,6 @@ def _serialize_document(document: Document) -> dict:
"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")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
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)
if model is None or model.status != "enabled":
content = _build_mock_content(paragraph)
content = build_mock_content(paragraph)
message = "当前未找到可用模型,返回本地模拟生成结果。"
else:
result = await call_ai(paragraph, model)
@@ -162,54 +128,32 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
)
db.add(document)
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.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),
@@ -263,9 +207,10 @@ async def cancel_document(document_id: int, db: AsyncSession = Depends(get_db)):
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
document.status = "cancelled"
await db.commit()
await db.refresh(document)
if document.status in {"completed", "failed", "cancelled"}:
return Response(data=_serialize_document(document))
request_cancel(document_id)
return Response(data=_serialize_document(document))
+12 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import re
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
@@ -60,6 +61,10 @@ def _build_prompt(paragraph: Paragraph) -> tuple[str, str]:
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"):
@@ -88,7 +93,7 @@ async def _post_with_retry(
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}",
f"上游模型响应异常: {response.status_code} - {response.text[:500]}",
request=response.request,
response=response,
)
@@ -99,7 +104,10 @@ async def _post_with_retry(
if attempt == settings.AI_MAX_RETRIES - 1:
break
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:
@@ -116,7 +124,7 @@ async def _call_openai_compatible(model: AiModel, system_prompt: str, user_promp
"temperature": 0.3,
}
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)
body = response.json()
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",
"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)
body = response.json()
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 与任务拆解清单,标记真实模型调用与文件上传相关能力的完成状态。
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 解析 + 数据统计)
### 文档生成器(3 天)
- [ ] 单段落生成流程
- [x] 单段落生成流程
- [ ] 多段落并行生成编排(asyncio.gather
- [ ] SSE 进度推送
- [ ] 取消生成支持
- [ ] 生成结果入库
- [x] SSE 进度推送
- [x] 取消生成支持
- [x] 生成结果入库
### Word 导出引擎(5-7 天)
- [ ] 复制原模板文件作为骨架
@@ -50,7 +50,7 @@
### 路由与 API3 天)
- [x] 模板 CRUD 路由
- [x] 模型 CRUD 路由
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
- [x] 生成相关路由(测试/全量/进度SSE/取消)
- [ ] 导出路由(Word/PDF
- [x] 文件上传/管理
+157 -36
View File
@@ -1,51 +1,172 @@
<template>
<a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }">
<a-layout style="min-height:100vh">
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible theme="light" :width="220">
<div class="logo">{{ collapsed ? "A" : "AI 文档模板" }}</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="onMenuClick">
<a-menu-item key="/templates"><folder-outlined /> 模板管理</a-menu-item>
<a-menu-item key="/models"><api-outlined /> 模型管理</a-menu-item>
<a-menu-item key="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header style="background:#fff;padding:0 24px;display:flex;align-items:center;border-bottom:1px solid #f0f0f0">
<menu-unfold-outlined v-if="collapsed" @click="collapsed=false" style="font-size:18px;cursor:pointer" />
<menu-fold-outlined v-else @click="collapsed=true" style="font-size:18px;cursor:pointer" />
<div style="flex:1" />
<a-badge :count="0" :overflow-count="99"><bell-outlined style="font-size:18px;color:#999" /></a-badge>
<span style="margin-left:16px;font-size:13px;color:#666">周文涛</span>
</a-layout-header>
<a-layout-content style="margin:0;background:#f5f6f8;overflow:auto">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
<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('/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 { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined,
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
} from '@ant-design/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const selectedKeys = ref([route.path])
watch(() => route.path, p => { selectedKeys.value = [p] })
function onMenuClick(info: any) {
router.push(info.key)
function isActive(path: string) {
return route.path.startsWith(path)
}
</script>
<style scoped>
.logo { height: 52px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: #5b5bd6; border-bottom: 1px solid #f0f0f0; }
.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>
+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">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { useDocumentStore } from "@/stores/document";
import { generateApi } from "@/api/generate";
import { message } from "ant-design-vue";
const router = useRouter();
const tplStore = useTemplateStore();
const docStore = useDocumentStore();
const templates = ref<any[]>([]);
const paragraphs = ref<any[]>([]);
const selectedTplId = ref(undefined);
const uploadedFiles = ref<Record<number,string>>({});
const uploadedFilePaths = ref<Record<number,string>>({});
const uploadingMap = ref<Record<number,boolean>>({});
const generating = ref(false);
const progress = ref(0);
const progressText = ref("");
const tplInfo = ref<any>({});
const needFileCount = computed(()=>paragraphs.value.filter(p=>p.need_file).length);
const fileCount = computed(()=>Object.keys(uploadedFiles.value).length);
onMounted(async()=>{await tplStore.fetchList();templates.value=tplStore.templates as any});
async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);paragraphs.value=tplStore.paragraphs as any;tplInfo.value={paragraph_count:tpl.paragraph_count,fileCount:paragraphs.value.filter((p:any)=>p.need_file).length};uploadedFiles.value={};uploadedFilePaths.value={};}
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}
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}}
import { computed, onBeforeUnmount, onMounted, ref } 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 { generateApi } from '@/api/generate'
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, string>>({})
const uploadedFilePaths = ref<Record<number, string>>({})
const uploadingMap = ref<Record<number, boolean>>({})
const generating = ref(false)
const progress = ref(0)
const progressText = ref('')
const tplInfo = ref<any>({})
const currentDocumentId = ref<number | null>(null)
let progressSource: EventSource | null = null
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>
<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>
<div style="padding:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>模型管理</h2>
<a-button type="primary" @click="openAdd">添加模型</a-button>
<div class="page-wrap">
<div class="breadcrumb">
<span>模型管理</span>
<span>/</span>
<span>AI 模型配置</span>
</div>
<a-row :gutter="[16, 16]">
<a-col :span="8" v-for="item in models" :key="item.id">
<a-card :title="item.name">
<template #extra>
<a-button type="link" size="small" @click="openEdit(item)">编辑</a-button>
</template>
<p>厂商{{ item.provider }}</p>
<p>格式{{ item.api_format }}</p>
<p>地址{{ item.api_endpoint }}</p>
<p>密钥{{ item.api_key_preview || '未设置' }}</p>
<p>状态<a-switch :checked="item.status === 'enabled'" @change="toggleStatus(item)" /></p>
<div style="margin-top:12px;display:flex;gap:8px">
<a-button size="small" @click="runTest(item)">连接测试</a-button>
<a-button danger size="small" @click="removeModel(item.id)">删除</a-button>
<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>
</a-card>
</a-col>
</a-row>
<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>
<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" placeholder="如 gpt-4o-mini / deepseek-chat / claude-3-5-sonnet-latest" />
<a-input v-model:value="form.name" />
</a-form-item>
<a-form-item label="供应厂商">
<a-input v-model:value="form.provider" />
@@ -39,10 +46,10 @@
</a-select>
</a-form-item>
<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 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>
</a-modal>
@@ -101,8 +108,7 @@ async function saveModel() {
}
async function toggleStatus(item: any) {
const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled'
await store.update(item.id, { status: nextStatus })
await store.update(item.id, { status: item.status === 'enabled' ? 'disabled' : 'enabled' })
await refreshList()
message.success('状态已更新')
}
@@ -112,17 +118,106 @@ async function runTest(item: any) {
const result: any = await store.test(item.id)
Modal.info({
title: '连接测试结果',
width: 640,
width: 680,
content: JSON.stringify(result.data, null, 2),
})
} catch (error: any) {
message.error(error.message || '连接测试失败')
}
}
async function removeModel(id: number) {
await store.remove(id)
await refreshList()
message.success('模型已删除')
}
</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>
+233 -16
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">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { FileAddOutlined } from "@ant-design/icons-vue";
const router = useRouter();
const store = useTemplateStore();
const templates = ref([]);
const uploadOpen = ref(false);
const uploadFile = ref<File | null>(null);
const columns = [{title:"名称",dataIndex:"name"},{title:"段落",dataIndex:"paragraph_count"},{title:"状态",dataIndex:"status"},{title:"操作",key:"action"}];
onMounted(async()=>{await store.fetchList();templates.value=store.templates as any});
function handleUpload(){uploadOpen.value=true}
function beforeUpload(file:File){uploadFile.value=file;return false}
async function doUpload(){if(!uploadFile.value)return;await store.upload(uploadFile.value);uploadOpen.value=false;router.push(`/templates/${store.currentTemplate?.id}/edit`)}
</script>
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>