From a2bad585910a61ca9370e857db670454a019f26a Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Thu, 2 Jul 2026 15:00:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=A8=A1=E5=9E=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E5=9F=BA=E7=A1=80=E7=94=9F=E6=88=90=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/export.py | 17 +++ backend/routers/generate.py | 217 ++++++++++++++++++++++++++- backend/routers/models.py | 96 +++++++++++- backend/schemas/schemas.py | 9 ++ backend/services/security.py | 35 +++++ docs/tasks/task_detail_2026_07_02.md | 13 ++ docs/需求与设计/03-任务拆解清单.md | 2 +- web/src/views/HistoryPage.vue | 91 +++++++++-- web/src/views/PreviewEdit.vue | 211 +++++++++++++++++++++++--- web/src/views/TemplateEditor.vue | 211 +++++++++++++++++++++++--- 10 files changed, 850 insertions(+), 52 deletions(-) create mode 100644 backend/services/security.py diff --git a/backend/routers/export.py b/backend/routers/export.py index af9233c..5c88beb 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -1,3 +1,20 @@ from fastapi import APIRouter +from fastapi.responses import PlainTextResponse 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", + ) diff --git a/backend/routers/generate.py b/backend/routers/generate.py index af9233c..2df9c8a 100644 --- a/backend/routers/generate.py +++ b/backend/routers/generate.py @@ -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() + + +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}) diff --git a/backend/routers/models.py b/backend/routers/models.py index af9233c..2fe3d85 100644 --- a/backend/routers/models.py +++ b/backend/routers/models.py @@ -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() + + +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} 配置校验通过(当前为本地模拟测试)", + } + ) diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py index bd07cde..8322d74 100644 --- a/backend/schemas/schemas.py +++ b/backend/schemas/schemas.py @@ -52,6 +52,15 @@ class AiModelCreate(BaseModel): api_key: str = "" 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): id: int name: str diff --git a/backend/services/security.py b/backend/services/security.py new file mode 100644 index 0000000..2642c5c --- /dev/null +++ b/backend/services/security.py @@ -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:]}" diff --git a/docs/tasks/task_detail_2026_07_02.md b/docs/tasks/task_detail_2026_07_02.md index 564fa04..9ec768c 100644 --- a/docs/tasks/task_detail_2026_07_02.md +++ b/docs/tasks/task_detail_2026_07_02.md @@ -12,3 +12,16 @@ 6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。 7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。 - **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。 + +## 会话 ID: local-20260702145958 +- [2026-07-02 14:59:58] +- **执行原因**: 用户要求先提交当前代码,并继续完善到可以初步使用的程度。 +- **执行过程**: + 1. 将首批模板解析与模板管理相关改动整理后提交,提交信息使用中文。 + 2. 新增模型管理后端接口,支持模型列表、创建、更新、删除、状态切换与本地模拟测试。 + 3. 新增加密工具,按项目要求对 API Key 做 Fernet 形式加密存储并提供脱敏展示。 + 4. 新增生成与记录接口,支持单段测试、整份文档模拟生成、历史列表、详情预览、取消与删除。 + 5. 将预览页接入真实生成记录数据,将模板编辑页补齐“保存配置”和“立即测试”能力,并让历史页显示动态统计。 + 6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。 + 7. 再次执行后端语法检查与前端类型检查,确保本轮改动可用。 +- **执行结果**: 当前系统已可初步走通“上传模板 → 配置段落 → 管理模型 → 触发生成 → 查看记录/预览”的联调链路,导出仍为占位提示实现。 diff --git a/docs/需求与设计/03-任务拆解清单.md b/docs/需求与设计/03-任务拆解清单.md index 8790524..a55f305 100644 --- a/docs/需求与设计/03-任务拆解清单.md +++ b/docs/需求与设计/03-任务拆解清单.md @@ -49,7 +49,7 @@ ### 路由与 API(3 天) - [x] 模板 CRUD 路由 -- [ ] 模型 CRUD 路由 +- [x] 模型 CRUD 路由 - [ ] 生成相关路由(测试/全量/进度SSE/取消) - [ ] 导出路由(Word/PDF) - [ ] 文件上传/管理 diff --git a/web/src/views/HistoryPage.vue b/web/src/views/HistoryPage.vue index 5ebf764..b3a7c94 100644 --- a/web/src/views/HistoryPage.vue +++ b/web/src/views/HistoryPage.vue @@ -1,14 +1,79 @@ - + + \ No newline at end of file +import { computed, ref, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import { useDocumentStore } from '@/stores/document' +import { generateApi } from '@/api/generate' + +const router = useRouter() +const store = useDocumentStore() +const documents = ref([]) + +const totalCount = computed(() => documents.value.length) +const completedCount = computed(() => documents.value.filter((item) => item.status === 'completed').length) +const cancelledCount = computed(() => documents.value.filter((item) => item.status === 'cancelled').length) +const failedCount = computed(() => documents.value.filter((item) => item.status === 'failed').length) + +function statusText(status: string) { + const map: Record = { + 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 +}) + diff --git a/web/src/views/PreviewEdit.vue b/web/src/views/PreviewEdit.vue index bf6d25e..b9e68c8 100644 --- a/web/src/views/PreviewEdit.vue +++ b/web/src/views/PreviewEdit.vue @@ -1,19 +1,196 @@ - + + - + + diff --git a/web/src/views/TemplateEditor.vue b/web/src/views/TemplateEditor.vue index ebbed4e..8594e62 100644 --- a/web/src/views/TemplateEditor.vue +++ b/web/src/views/TemplateEditor.vue @@ -1,21 +1,194 @@ - + + - \ No newline at end of file + +