按原型图重构核心页面并完善生成链路
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
|
||||
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
|
||||
- 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果
|
||||
- 生成过程中支持 SSE 进度推送与取消生成
|
||||
- 查看生成记录与预览页真实结果
|
||||
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
|
||||
|
||||
|
||||
+34
-89
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
@@ -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 @@
|
||||
### 路由与 API(3 天)
|
||||
- [x] 模板 CRUD 路由
|
||||
- [x] 模型 CRUD 路由
|
||||
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
|
||||
- [x] 生成相关路由(测试/全量/进度SSE/取消)
|
||||
- [ ] 导出路由(Word/PDF)
|
||||
- [x] 文件上传/管理
|
||||
|
||||
|
||||
+157
-36
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user