补齐模型管理与基础生成预览链路

This commit is contained in:
zwt13703
2026-07-02 15:00:11 +08:00
parent b15a3a1f18
commit a2bad58591
10 changed files with 850 additions and 52 deletions
+17
View File
@@ -1,3 +1,20 @@
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import PlainTextResponse
router = APIRouter() router = APIRouter()
@router.get("/{document_id}/docx")
async def export_docx(document_id: int):
return PlainTextResponse(
f"文档 {document_id} 的 Word 导出功能正在开发中,当前版本请先使用预览页查看结果。",
media_type="text/plain; charset=utf-8",
)
@router.get("/{document_id}/pdf")
async def export_pdf(document_id: int):
return PlainTextResponse(
f"文档 {document_id} 的 PDF 导出功能正在开发中,当前版本请先使用预览页查看结果。",
media_type="text/plain; charset=utf-8",
)
+216 -1
View File
@@ -1,3 +1,218 @@
from fastapi import APIRouter import json
import time
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
router = APIRouter() router = APIRouter()
def _serialize_document(document: Document) -> dict:
return {
"id": document.id,
"template_id": document.template_id,
"name": document.name,
"para_count_done": document.para_count_done,
"para_count_total": document.para_count_total,
"status": document.status,
"file_path": document.file_path,
"error": document.error,
"created_at": document.created_at,
"updated_at": document.updated_at,
}
def _build_mock_content(paragraph: Paragraph) -> dict:
if paragraph.output_format == "table":
return {
"content": [
{
"type": "table",
"title": paragraph.title,
"headers": ["字段", "内容"],
"rows": [
["段落标题", paragraph.title],
["生成说明", paragraph.prompt_text or "根据模板内容生成"],
],
}
]
}
blocks = [
{
"type": "text",
"text": f"这是“{paragraph.title}”的示例生成内容,可用于前端联调与流程验证。"
}
]
if paragraph.content:
blocks.append({"type": "text", "text": f"模板上下文:{paragraph.content[:200]}"})
if paragraph.need_prompt and paragraph.prompt_text:
blocks.append({"type": "text", "text": f"预设提示词:{paragraph.prompt_text[:200]}"})
return {"content": blocks}
@router.post("/test")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
if paragraph is None or paragraph.template_id != body.template_id:
raise HTTPException(status_code=404, detail="段落不存在")
content = _build_mock_content(paragraph)
return Response(
data={
"paragraph_id": paragraph.id,
"content": content,
"message": "当前返回本地模拟生成结果,便于前端联调。",
}
)
@router.post("/full")
async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)):
template = await db.get(Template, body.template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == body.template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
paragraphs = result.scalars().all()
if not paragraphs:
raise HTTPException(status_code=400, detail="模板下暂无可生成段落")
document = Document(
template_id=template.id,
name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
para_count_done=0,
para_count_total=len(paragraphs),
status="generating",
file_path="",
error="",
)
db.add(document)
await db.flush()
done_count = 0
for paragraph in paragraphs:
if paragraph.edit_mode == "manual":
content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]}
else:
start = time.perf_counter()
content = _build_mock_content(paragraph)
duration = round(time.perf_counter() - start, 4)
log = GenerationLog(
document_id=document.id,
paragraph_id=paragraph.id,
model_id=paragraph.model_id,
status="success",
content=json.dumps(content, ensure_ascii=False),
duration=duration,
error_msg="",
)
db.add(log)
done_count += 1
continue
log = GenerationLog(
document_id=document.id,
paragraph_id=paragraph.id,
model_id=paragraph.model_id,
status="success",
content=json.dumps(content, ensure_ascii=False),
duration=0,
error_msg="",
)
db.add(log)
done_count += 1
document.para_count_done = done_count
document.status = "completed"
document.file_path = f"mock://document/{document.id}"
await db.commit()
await db.refresh(document)
return Response(data=_serialize_document(document))
@router.get("/documents")
async def list_documents(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
total = (await db.execute(select(func.count(Document.id)))).scalar_one()
result = await db.execute(
select(Document)
.order_by(Document.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [_serialize_document(item) for item in result.scalars().all()]
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
@router.get("/documents/{document_id}")
async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
log_result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
items = []
for log, paragraph in log_result.all():
items.append(
{
"id": log.id,
"paragraph_id": paragraph.id,
"title": paragraph.title,
"sort_index": paragraph.sort_index,
"status": log.status,
"content": json.loads(log.content) if log.content else {"content": []},
}
)
payload = _serialize_document(document)
payload["logs"] = items
return Response(data=payload)
@router.post("/cancel/{document_id}")
async def cancel_document(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
document.status = "cancelled"
await db.commit()
await db.refresh(document)
return Response(data=_serialize_document(document))
@router.delete("/documents/{document_id}")
async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
result = await db.execute(select(GenerationLog).where(GenerationLog.document_id == document_id))
for log in result.scalars().all():
await db.delete(log)
await db.delete(document)
await db.commit()
return Response(data={"id": document_id})
+95 -1
View File
@@ -1,3 +1,97 @@
from fastapi import APIRouter from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models.ai_model import AiModel
from schemas.schemas import AiModelCreate, AiModelUpdate, Response
from services.security import decrypt_text, encrypt_text, mask_secret
router = APIRouter() router = APIRouter()
def _serialize_model(model: AiModel) -> dict:
api_key = decrypt_text(model.api_key_encrypted)
return {
"id": model.id,
"name": model.name,
"provider": model.provider,
"api_format": model.api_format,
"api_endpoint": model.api_endpoint,
"api_key_preview": mask_secret(api_key),
"status": model.status,
"created_at": model.created_at,
}
@router.get("")
async def list_models(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AiModel).order_by(AiModel.id.desc()))
items = [_serialize_model(item) for item in result.scalars().all()]
return Response(data=items)
@router.post("")
async def create_model(body: AiModelCreate, db: AsyncSession = Depends(get_db)):
model = AiModel(
name=body.name,
provider=body.provider,
api_format=body.api_format,
api_endpoint=body.api_endpoint,
api_key_encrypted=encrypt_text(body.api_key),
status=body.status,
)
db.add(model)
await db.commit()
await db.refresh(model)
return Response(data=_serialize_model(model))
@router.put("/{model_id}")
async def update_model(model_id: int, body: AiModelUpdate, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
if body.name is not None:
model.name = body.name
if body.provider is not None:
model.provider = body.provider
if body.api_format is not None:
model.api_format = body.api_format
if body.api_endpoint is not None:
model.api_endpoint = body.api_endpoint
if body.status is not None:
model.status = body.status
if body.api_key:
model.api_key_encrypted = encrypt_text(body.api_key)
await db.commit()
await db.refresh(model)
return Response(data=_serialize_model(model))
@router.delete("/{model_id}")
async def delete_model(model_id: int, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
await db.delete(model)
await db.commit()
return Response(data={"id": model_id})
@router.post("/{model_id}/test")
async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
model = await db.get(AiModel, model_id)
if model is None:
raise HTTPException(status_code=404, detail="模型不存在")
return Response(
data={
"id": model.id,
"success": True,
"message": f"模型 {model.name} 配置校验通过(当前为本地模拟测试)",
}
)
+9
View File
@@ -52,6 +52,15 @@ class AiModelCreate(BaseModel):
api_key: str = "" api_key: str = ""
status: str = "enabled" status: str = "enabled"
class AiModelUpdate(BaseModel):
name: Optional[str] = None
provider: Optional[str] = None
api_format: Optional[str] = None
api_endpoint: Optional[str] = None
api_key: str = ""
status: Optional[str] = None
class AiModelOut(BaseModel): class AiModelOut(BaseModel):
id: int id: int
name: str name: str
+35
View File
@@ -0,0 +1,35 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from config import settings
def _build_fernet() -> Fernet:
raw_key = settings.ENCRYPTION_KEY.encode("utf-8")
digest = hashlib.sha256(raw_key).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_text(value: str) -> str:
if not value:
return ""
return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_text(value: str) -> str:
if not value:
return ""
try:
return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken:
return ""
def mask_secret(value: str) -> str:
if not value:
return ""
if len(value) <= 7:
return "*" * len(value)
return f"{value[:3]}****{value[-4:]}"
+13
View File
@@ -12,3 +12,16 @@
6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。 6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。
7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。 7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。
- **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。 - **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。
## 会话 ID: local-20260702145958
- [2026-07-02 14:59:58]
- **执行原因**: 用户要求先提交当前代码,并继续完善到可以初步使用的程度。
- **执行过程**:
1. 将首批模板解析与模板管理相关改动整理后提交,提交信息使用中文。
2. 新增模型管理后端接口,支持模型列表、创建、更新、删除、状态切换与本地模拟测试。
3. 新增加密工具,按项目要求对 API Key 做 Fernet 形式加密存储并提供脱敏展示。
4. 新增生成与记录接口,支持单段测试、整份文档模拟生成、历史列表、详情预览、取消与删除。
5. 将预览页接入真实生成记录数据,将模板编辑页补齐“保存配置”和“立即测试”能力,并让历史页显示动态统计。
6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。
7. 再次执行后端语法检查与前端类型检查,确保本轮改动可用。
- **执行结果**: 当前系统已可初步走通“上传模板 → 配置段落 → 管理模型 → 触发生成 → 查看记录/预览”的联调链路,导出仍为占位提示实现。
@@ -49,7 +49,7 @@
### 路由与 API3 天) ### 路由与 API3 天)
- [x] 模板 CRUD 路由 - [x] 模板 CRUD 路由
- [ ] 模型 CRUD 路由 - [x] 模型 CRUD 路由
- [ ] 生成相关路由(测试/全量/进度SSE/取消) - [ ] 生成相关路由(测试/全量/进度SSE/取消)
- [ ] 导出路由(Word/PDF - [ ] 导出路由(Word/PDF
- [ ] 文件上传/管理 - [ ] 文件上传/管理
+77 -12
View File
@@ -1,14 +1,79 @@
<template><div style="padding:24px"><a-row :gutter="16" style="margin-bottom:24px"><a-col :span="6"><a-card><a-statistic title="总生成" :value="12" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="成功" :value="10" value-style="color:#52c41a" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="中断" :value="1" value-style="color:#faad14" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="失败" :value="1" value-style="color:#ff4d4f" /></a-card></a-col></a-row><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h3>生成记录</h3><div><a-select style="width:140px;margin-right:8px" placeholder="全部模板" /><a-select style="width:120px" placeholder="全部状态" /></div></div><a-list :dataSource="documents" :grid="{gutter:16,xs:1,sm:1,md:2,lg:2,xl:3,xxl:3}"><template #renderItem="{item}"><a-list-item><a-card hoverable><a-card-meta :title="item.name"><template #description><p>模板{{item.template_id}}</p><p>段落{{item.para_count_done}}/{{item.para_count_total}}</p><p>状态<a-badge :status="item.status==='completed'?'success':item.status==='failed'?'error':'processing'" :text="statusText(item.status)" /></p></template></a-card-meta><template #actions><a-button type="link" @click="preview(item.id)">预览</a-button><a-button type="link" @click="download(item.id)">下载</a-button></template></a-card></a-list-item></template></a-list></div></template> <template>
<div style="padding:24px">
<a-row :gutter="16" style="margin-bottom:24px">
<a-col :span="6"><a-card><a-statistic title="总生成" :value="totalCount" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="成功" :value="completedCount" value-style="color:#52c41a" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="中断" :value="cancelledCount" value-style="color:#faad14" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="失败" :value="failedCount" value-style="color:#ff4d4f" /></a-card></a-col>
</a-row>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3>生成记录</h3>
</div>
<a-list :dataSource="documents" :grid="{gutter:16,xs:1,sm:1,md:2,lg:2,xl:3,xxl:3}">
<template #renderItem="{item}">
<a-list-item>
<a-card hoverable>
<a-card-meta :title="item.name">
<template #description>
<p>模板{{ item.template_id }}</p>
<p>段落{{ item.para_count_done }}/{{ item.para_count_total }}</p>
<p>状态<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" /></p>
</template>
</a-card-meta>
<template #actions>
<a-button type="link" @click="preview(item.id)">预览</a-button>
<a-button type="link" @click="download(item.id)">下载</a-button>
</template>
</a-card>
</a-list-item>
</template>
</a-list>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { computed, ref, onMounted } from 'vue'
import { useRouter } from "vue-router"; import { useRouter } from 'vue-router'
import { useDocumentStore } from "@/stores/document"; import { useDocumentStore } from '@/stores/document'
import { generateApi } from "@/api/generate"; import { generateApi } from '@/api/generate'
const router = useRouter();
const store = useDocumentStore(); const router = useRouter()
const documents = ref<any[]>([]); const store = useDocumentStore()
function statusText(s:string){const m:Record<string,string>={'completed':'已生成','failed':'失败','cancelled':'中断','generating':'生成中','pending':'等待中'};return m[s]||s} const documents = ref<any[]>([])
onMounted(async()=>{await store.fetchList();documents.value=store.documents as any});
function preview(id:number){router.push(`/preview/${id}`)} const totalCount = computed(() => documents.value.length)
function download(id:number){window.open(generateApi.exportDocx(id))} const completedCount = computed(() => documents.value.filter((item) => item.status === 'completed').length)
const cancelledCount = computed(() => documents.value.filter((item) => item.status === 'cancelled').length)
const failedCount = computed(() => documents.value.filter((item) => item.status === 'failed').length)
function statusText(status: string) {
const map: Record<string, string> = {
completed: '已生成',
failed: '失败',
cancelled: '中断',
generating: '生成中',
pending: '等待中',
}
return map[status] || status
}
function statusBadge(status: string) {
if (status === 'completed') return 'success'
if (status === 'failed') return 'error'
if (status === 'cancelled') return 'warning'
return 'processing'
}
function preview(id: number) {
router.push(`/preview/${id}`)
}
function download(id: number) {
window.open(generateApi.exportDocx(id))
}
onMounted(async () => {
await store.fetchList()
documents.value = store.documents as any
})
</script> </script>
+194 -17
View File
@@ -1,19 +1,196 @@
<template><div style="padding:24px"><a-card><template #title><span style="display:flex;align-items:center;gap:8px"><file-text-outlined /> 文档编辑</span></template><template #extra><a-button-group><a-button @click="undo"><undo-outlined />撤销</a-button><a-button @click="redo"><redo-outlined />重做</a-button></a-button-group><a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button><a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button></template><div style="display:flex;gap:16px"><div style="flex:1;min-width:0"><div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap"><a-button size="small"><b>B</b></a-button><a-button size="small"><i>I</i></a-button><a-button size="small"><u>U</u></a-button><a-divider type="vertical" /><a-button size="small"><font-size-outlined /></a-button><a-button size="small"><ordered-list-outlined /></a-button><a-button size="small"><table-outlined /></a-button></div><div ref="editorRef" contenteditable="true" style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8" v-html="editorHtml" @input="onEdit"></div></div><div style="width:200px;flex-shrink:0"><a-card title="文档结构" size="small"><div v-for="s in structure" :key="s.id" :class="['struct-item',{active:s.active}]" @click="scrollTo(s.id)"><file-text-outlined /> {{s.title}}</div></a-card></div></div></a-card></div></template> <template>
<div style="padding:24px">
<a-card>
<template #title>
<span style="display:flex;align-items:center;gap:8px">
<file-text-outlined />
文档预览
</span>
</template>
<template #extra>
<a-button-group>
<a-button @click="undo"><undo-outlined />撤销</a-button>
<a-button @click="redo"><redo-outlined />重做</a-button>
</a-button-group>
<a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button>
<a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button>
</template>
<div style="display:flex;gap:16px">
<div style="flex:1;min-width:0">
<div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap">
<a-button size="small"><b>B</b></a-button>
<a-button size="small"><i>I</i></a-button>
<a-button size="small"><u>U</u></a-button>
<a-divider type="vertical" />
<a-button size="small"><font-size-outlined /></a-button>
<a-button size="small"><ordered-list-outlined /></a-button>
<a-button size="small"><table-outlined /></a-button>
</div>
<div
ref="editorRef"
contenteditable="true"
style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8;background:#fff"
v-html="editorHtml"
@input="onEdit"
/>
</div>
<div style="width:220px;flex-shrink:0">
<a-card title="文档结构" size="small">
<div
v-for="s in structure"
:key="s.id"
class="struct-item"
@click="scrollTo(s.anchor)"
>
<file-text-outlined />
{{ s.title }}
</div>
</a-card>
</div>
</div>
</a-card>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { computed, ref, onMounted } from 'vue'
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router'
import { generateApi } from "@/api/generate"; import { generateApi } from '@/api/generate'
import { message } from "ant-design-vue"; import { message } from 'ant-design-vue'
import { FileTextOutlined, UndoOutlined, RedoOutlined, FontSizeOutlined, OrderedListOutlined, TableOutlined } from "@ant-design/icons-vue"; import {
const route = useRoute(); FileTextOutlined,
const editorRef = ref<HTMLElement|null>(null); UndoOutlined,
const editorHtml = ref(`<h2 style="text-align:center">2025年第一季度经济活动分析报告</h2><p style="text-align:center;color:#666">某某集团有限公司</p><h3>一、主要经营指标完成情况</h3><div style="background:#f0f0ff;padding:12px;border-left:3px solid #5b5bd6;border-radius:4px;margin:8px 0"><p>本季度营业收入完成<strong>12.35亿元</strong>,同比上升<strong>8.7%</strong>。</p></div><h3>主要经营指标完成情况表</h3><table border="1" style="width:100%;border-collapse:collapse"><tr><th>指标</th><th>完成值</th><th>同比</th></tr><tr><td>营业收入</td><td>12.35亿</td><td>+8.7%</td></tr></table><h3>二、成本费用分析</h3><p>本季度总成本9.87亿元,同比上升6.2%。</p>`); RedoOutlined,
const structure = ref([{id:'s1',title:'经营指标',active:true},{id:'s2',title:'指标表',active:false},{id:'s3',title:'成本费用',active:false}]); FontSizeOutlined,
function scrollTo(id:string){} OrderedListOutlined,
function onEdit(){} TableOutlined,
function undo(){document.execCommand('undo')} } from '@ant-design/icons-vue'
function redo(){document.execCommand('redo')}
function exportDocx(){const id=route.params.id;window.open(generateApi.exportDocx(Number(id)))} interface ContentBlock {
function exportPdf(){const id=route.params.id;window.open(generateApi.exportPdf(Number(id)))} type: string
text?: string
title?: string
headers?: string[]
rows?: string[][]
}
interface LogItem {
paragraph_id: number
title: string
sort_index: number
content: {
content: ContentBlock[]
}
}
const route = useRoute()
const editorRef = ref<HTMLElement | null>(null)
const logs = ref<LogItem[]>([])
const editorHtml = ref('<p>加载中...</p>')
const structure = computed(() =>
logs.value.map((item) => ({
id: item.paragraph_id,
title: item.title || `段落 ${item.sort_index}`,
anchor: `section-${item.paragraph_id}`,
})),
)
function renderTable(block: ContentBlock) {
const headers = block.headers || []
const rows = block.rows || []
const thead = headers.length
? `<thead><tr>${headers.map((header) => `<th>${header}</th>`).join('')}</tr></thead>`
: ''
const tbody = `<tbody>${rows
.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`)
.join('')}</tbody>`
return `<table border="1" style="width:100%;border-collapse:collapse;margin:12px 0">${thead}${tbody}</table>`
}
function renderBlocks(blocks: ContentBlock[]) {
return blocks
.map((block) => {
if (block.type === 'table') {
return renderTable(block)
}
return `<p style="margin:10px 0">${block.text || ''}</p>`
})
.join('')
}
async function loadDocument() {
const id = Number(route.params.id)
const response: any = await generateApi.getDocument(id)
logs.value = response.data?.logs || []
if (!logs.value.length) {
editorHtml.value = '<p>暂无生成内容。</p>'
return
}
editorHtml.value = logs.value
.map(
(item) => `
<section id="section-${item.paragraph_id}" style="margin-bottom:24px">
<h3 style="margin-bottom:12px">${item.title}</h3>
<div style="background:#f8faff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px">
${renderBlocks(item.content?.content || [])}
</div>
</section>
`,
)
.join('')
}
function scrollTo(anchor: string) {
const element = document.getElementById(anchor)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function onEdit() {}
function undo() {
document.execCommand('undo')
}
function redo() {
document.execCommand('redo')
}
function exportDocx() {
const id = Number(route.params.id)
window.open(generateApi.exportDocx(id))
}
function exportPdf() {
const id = Number(route.params.id)
window.open(generateApi.exportPdf(id))
}
onMounted(async () => {
try {
await loadDocument()
} catch (error: any) {
message.error(error.message || '加载文档失败')
editorHtml.value = '<p>文档加载失败。</p>'
}
})
</script> </script>
<style scoped>.struct-item{padding:8px;cursor:pointer;border-radius:4px;font-size:13px}.struct-item:hover{background:#f5f5f5}.struct-item.active{background:#f0f0ff;color:#5b5bd6}</style>
<style scoped>
.struct-item {
padding: 8px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
display: flex;
gap: 6px;
align-items: center;
}
.struct-item:hover {
background: #f5f5f5;
}
</style>
+192 -19
View File
@@ -1,21 +1,194 @@
<template><div style="display:flex;height:calc(100vh - 112px)"><div style="width:220px;border-right:1px solid #f0f0f0;overflow-y:auto;padding:12px"><h4 style="margin-bottom:12px">段落列表</h4><div v-for="(p,i) in paragraphs" :key="p.id" :class="['para-item',{active:selectedId===p.id}]" @click="selectPara(p.id)"><span class="idx">{{i+1}}</span><span class="title">{{p.title||'未命名'}}</span><a-tag :color="p.edit_mode==='ai'?'blue':'orange'" style="margin-left:auto">{{p.edit_mode==='ai'?'AI':'手动'}}</a-tag></div></div><div style="flex:1;overflow-y:auto;padding:24px;background:#f0f1f3;display:flex;justify-content:center;align-items:flex-start"><div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08)" v-html="previewHtml"></div></div><div style="width:380px;border-left:1px solid #f0f0f0;overflow-y:auto;padding:16px"><h4>段落配置</h4><div v-if="selectedPara"><a-form layout="vertical"><a-form-item label="编辑方式"><a-select v-model:value="selectedPara.edit_mode"><a-select-option value="ai">AI 生成</a-select-option><a-select-option value="manual">人工编辑</a-select-option></a-select></a-form-item><a-form-item v-if="selectedPara.edit_mode==='ai'" label="生成模型"><a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型"><a-select-option v-for="m in models" :key="m.id" :value="m.id">{{m.name}}</a-select-option></a-select></a-form-item><a-form-item label="输出格式"><a-select v-model:value="selectedPara.output_format"><a-select-option value="text">正式报告段落</a-select-option><a-select-option value="table">表格形式</a-select-option><a-select-option value="mixed">混合内容</a-select-option><a-select-option value="chart">图表形式</a-select-option></a-select></a-form-item><a-form-item label="需要提示词"><a-switch v-model:checked="selectedPara.need_prompt" /></a-form-item><a-form-item v-if="selectedPara.need_prompt" label="预设提示词"><a-textarea v-model:value="selectedPara.prompt_text" :rows="4" placeholder="在此输入提示词..." /></a-form-item><a-form-item label="需要参考文件"><a-switch v-model:checked="selectedPara.need_file" /></a-form-item><a-form-item v-if="selectedPara.need_file" label="备注说明"><a-textarea v-model:value="selectedPara.file_note" :rows="2" placeholder="提示用户上传什么文件" /></a-form-item></a-form><a-button v-if="selectedPara.edit_mode==='ai'" type="primary" block @click="testPara">立即测试</a-button></div></div></div></template> <template>
<div style="display:flex;height:calc(100vh - 112px)">
<div style="width:220px;border-right:1px solid #f0f0f0;overflow-y:auto;padding:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="margin:0">段落列表</h4>
<a-button type="primary" size="small" @click="saveTemplate">保存</a-button>
</div>
<div
v-for="(p, i) in paragraphs"
:key="p.id"
:class="['para-item', { active: selectedId === p.id }]"
@click="selectPara(p.id)"
>
<span class="idx">{{ i + 1 }}</span>
<span class="title">{{ p.title || '未命名' }}</span>
<a-tag :color="p.edit_mode === 'ai' ? 'blue' : 'orange'" style="margin-left:auto">
{{ p.edit_mode === 'ai' ? 'AI' : '手动' }}
</a-tag>
</div>
</div>
<div style="flex:1;overflow-y:auto;padding:24px;background:#f0f1f3;display:flex;justify-content:center;align-items:flex-start">
<div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08)" v-html="previewHtml" />
</div>
<div style="width:380px;border-left:1px solid #f0f0f0;overflow-y:auto;padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="margin:0">段落配置</h4>
<a-button @click="saveTemplate">保存配置</a-button>
</div>
<div v-if="selectedPara">
<a-form layout="vertical">
<a-form-item label="编辑方式">
<a-select v-model:value="selectedPara.edit_mode">
<a-select-option value="ai">AI 生成</a-select-option>
<a-select-option value="manual">人工编辑</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型">
<a-select-option v-for="m in models" :key="m.id" :value="m.id">{{ m.name }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="输出格式">
<a-select v-model:value="selectedPara.output_format">
<a-select-option value="text">正式报告段落</a-select-option>
<a-select-option value="table">表格形式</a-select-option>
<a-select-option value="mixed">混合内容</a-select-option>
<a-select-option value="chart">图表形式</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="需要提示词">
<a-switch v-model:checked="selectedPara.need_prompt" />
</a-form-item>
<a-form-item v-if="selectedPara.need_prompt" label="预设提示词">
<a-textarea v-model:value="selectedPara.prompt_text" :rows="4" placeholder="在此输入提示词..." />
</a-form-item>
<a-form-item label="需要参考文件">
<a-switch v-model:checked="selectedPara.need_file" />
</a-form-item>
<a-form-item v-if="selectedPara.need_file" label="备注说明">
<a-textarea v-model:value="selectedPara.file_note" :rows="2" placeholder="提示用户上传什么文件" />
</a-form-item>
</a-form>
<a-button v-if="selectedPara.edit_mode === 'ai'" type="primary" block @click="testPara">立即测试</a-button>
</div>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from "vue-router"; import { useRoute } from 'vue-router'
import { useTemplateStore } from "@/stores/template"; import { Modal, message } from 'ant-design-vue'
import { useModelStore } from "@/stores/model"; import { useTemplateStore } from '@/stores/template'
import { message } from "ant-design-vue"; import { useModelStore } from '@/stores/model'
const route = useRoute(); import { generateApi } from '@/api/generate'
const router = useRouter();
const store = useTemplateStore(); const route = useRoute()
const modelStore = useModelStore(); const store = useTemplateStore()
const paragraphs = ref<any[]>([]); const modelStore = useModelStore()
const models = ref<any[]>([]); const paragraphs = ref<any[]>([])
const selectedId = ref(0); const models = ref<any[]>([])
const selectedPara = computed(()=>paragraphs.value.find(p=>p.id===selectedId.value)); const selectedId = ref(0)
const previewHtml = computed(()=>{return paragraphs.value.map(p=>'<div style="margin:8px 0;padding:8px;border:2px solid transparent;border-radius:4px'+(selectedId.value===p.id?';border-color:#5b5bd6;background:#f0f0ff':'')+'"><h3>'+(p.title||"")+'</h3><p>'+(p.content||"点击左侧配置此段落")+'</p></div>').join("")});
onMounted(async()=>{const id = Number(route.params.id);await store.fetchOne(id);paragraphs.value=store.paragraphs as any;await modelStore.fetchList();models.value=modelStore.models as any;if(paragraphs.value.length)selectedId.value=paragraphs.value[0].id}); const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
function selectPara(id:number){selectedId.value=id;const el=document.getElementById('para-'+id);if(el)el.scrollIntoView({behavior:'smooth',block:'center'})} const previewHtml = computed(() =>
async function testPara(){message.info('段落测试功能待实现')} paragraphs.value
.map(
(item) =>
`<div style="margin:8px 0;padding:8px;border:2px solid transparent;border-radius:4px${
selectedId.value === item.id ? ';border-color:#5b5bd6;background:#f0f0ff' : ''
}"><h3>${item.title || ''}</h3><p>${item.content || '点击左侧配置此段落'}</p></div>`,
)
.join(''),
)
function selectPara(id: number) {
selectedId.value = id
}
async function saveTemplate() {
const templateId = Number(route.params.id)
await store.save(templateId)
message.success('模板配置已保存')
}
async function testPara() {
if (!selectedPara.value) {
return
}
const templateId = Number(route.params.id)
const response: any = await generateApi.test({
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: [],
})
const blocks = response.data?.content?.content || []
const text = blocks
.map((block: any) => {
if (block.type === 'table') {
return `${block.title || '表格'}${(block.rows || []).length}`
}
return block.text || ''
})
.filter(Boolean)
.join('\n\n')
Modal.info({
title: '段落测试结果',
width: 640,
content: text || '当前未返回可展示内容。',
})
}
onMounted(async () => {
const id = Number(route.params.id)
await store.fetchOne(id)
paragraphs.value = store.paragraphs as any
await modelStore.fetchList()
models.value = modelStore.models as any
if (paragraphs.value.length) {
selectedId.value = paragraphs.value[0].id
}
})
</script> </script>
<style scoped>.para-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;margin-bottom:2px;font-size:13px}.para-item:hover{background:#f5f5f5}.para-item.active{background:#f0f0ff;color:#5b5bd6;font-weight:500}.para-item .idx{width:20px;height:20px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}.para-item.active .idx{background:#5b5bd6;color:#fff}.para-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}</style>
<style scoped>
.para-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
font-size: 13px;
}
.para-item:hover {
background: #f5f5f5;
}
.para-item.active {
background: #f0f0ff;
color: #5b5bd6;
font-weight: 500;
}
.para-item .idx {
width: 20px;
height: 20px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.para-item.active .idx {
background: #5b5bd6;
color: #fff;
}
.para-item .title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>