阶段 3 剩余:编辑与版本化
功能 文件 说明 幻灯片显隐 routes.py, PptResult.vue, ppt-api.ts 每页可点击隐藏/恢复,隐藏页在缩略图中半透明显示,页码标注隐藏数量 DeckContract 快照 renderer.py, celery_tasks.py, ppt_session.py 生成时保存完整 deck JSON 到文件系统,供版本化回溯 版本化生成 celery_tasks.py, task_service.py, routes.py POST /preview/:id/regenerate → 递增版本号 → 用 deck 重新渲染 → 追加到版本历史,不覆盖旧版 版本历史 UI PptResult.vue 右栏显示版本列表(版本号/页数/时间),当前版本高亮标记 数据库迁移 migrate_025.py 新增 deck_contract_path、versions_json 字段 阶段 4:UI 重构 功能 文件 说明 保险绿主题 PptPage.vue, PptGenerate.vue, PptResult.vue, PptSlideCanvas.vue 主色从 #2563eb 蓝改为 #3B7A57 保险绿,背景暖灰 #f0f7f3 生成决策展示 PptGenerate.vue 新增"系统决策"区域,展示检测场景、选用模板、计划书数量、险种类型、保司 骨架屏加载 PptResult.vue preview_status=none/generating 时显示骨架屏 + 加载动画,不再显示静态 fallback 新增后端接口(本轮) 方法 路径 功能 POST /ppt/preview/:id/regenerate 基于编辑内容生成新版本 PUT /ppt/preview/:id/slide/:index 增加 hidden 字段支持 新增 Celery 任务 任务名 说明 insurance.regenerate_ppt 读取 DeckContract → 应用编辑 → 重新渲染 → 保存新版本
This commit is contained in:
parent
f23b08eec6
commit
25c6f2a3b7
@ -14,6 +14,8 @@ def migrate():
|
||||
"slides_json_path": "VARCHAR(500) DEFAULT NULL",
|
||||
"quality_report_json": "TEXT DEFAULT NULL",
|
||||
"preview_status": "VARCHAR(20) NOT NULL DEFAULT 'none'",
|
||||
"deck_contract_path": "VARCHAR(500) DEFAULT NULL",
|
||||
"versions_json": "TEXT DEFAULT NULL",
|
||||
}
|
||||
|
||||
for col_name, col_def in columns.items():
|
||||
|
||||
@ -420,6 +420,18 @@ def _execute_ppt_generate(task_id: str):
|
||||
|
||||
_update_task_status(task_id, stage="saving", progress=90, message="保存文件")
|
||||
|
||||
# 保存 DeckContract 快照(供后续版本化编辑回溯)
|
||||
deck_path = None
|
||||
deck_data = result.get("deck")
|
||||
if deck_data:
|
||||
try:
|
||||
deck_path = os.path.join(output_dir, "deck_contract.json")
|
||||
with open(deck_path, "w", encoding="utf-8") as _f:
|
||||
json.dump(deck_data, _f, ensure_ascii=False, indent=2)
|
||||
logger.info("DeckContract 已保存: %s", deck_path)
|
||||
except Exception as exc:
|
||||
logger.warning("保存 DeckContract 失败: %s", exc)
|
||||
|
||||
# 解析幻灯片结构(失败不阻断主流程)
|
||||
slides_data = None
|
||||
slides_dir = os.path.join(output_dir, "slides")
|
||||
@ -470,6 +482,19 @@ def _execute_ppt_generate(task_id: str):
|
||||
session.preview_status = "failed"
|
||||
if quality_report:
|
||||
session.quality_report_json = json.dumps(quality_report, ensure_ascii=False)
|
||||
if deck_path:
|
||||
session.deck_contract_path = deck_path
|
||||
# 追加版本历史
|
||||
versions = json.loads(session.versions_json) if session.versions_json else []
|
||||
versions.append({
|
||||
"revision": session.generated_revision,
|
||||
"path": output_path,
|
||||
"slidesJsonPath": os.path.join(slides_dir, "slides.json") if slides_data else None,
|
||||
"deckPath": deck_path,
|
||||
"slideCount": result.get("slideCount", 0),
|
||||
"createdAt": datetime.now().isoformat(),
|
||||
})
|
||||
session.versions_json = json.dumps(versions, ensure_ascii=False)
|
||||
db.session.add(PptHistory(
|
||||
user_id=task.user_id,
|
||||
session_id=task.workspace_id,
|
||||
@ -505,6 +530,204 @@ def _execute_ppt_generate(task_id: str):
|
||||
)
|
||||
|
||||
|
||||
# ─── PPT 重新生成任务(版本化)─────────────────────────────
|
||||
|
||||
@shared_task(bind=True, name="insurance.regenerate_ppt", max_retries=3, default_retry_delay=30)
|
||||
def regenerate_ppt_task(self, task_id: str):
|
||||
"""PPT 重新生成任务(基于编辑内容生成新版本)。"""
|
||||
if not _claim_task(task_id):
|
||||
logger.info(f"任务 {task_id} 已被领取或不在 queued 状态,跳过")
|
||||
return
|
||||
|
||||
_update_task_status(task_id, stage="preparing", progress=5, message="准备重新生成")
|
||||
|
||||
try:
|
||||
_execute_ppt_regenerate(task_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"PPT 重新生成任务失败 [{task_id}]: {exc}", exc_info=True)
|
||||
_update_task_status(task_id,
|
||||
status="failed",
|
||||
error_code="regenerate_error",
|
||||
error_message=str(exc)[:1000],
|
||||
finished_at=datetime.now())
|
||||
raise
|
||||
|
||||
|
||||
def _execute_ppt_regenerate(task_id: str):
|
||||
"""执行 PPT 重新生成:读取 DeckContract → 应用编辑 → 重新渲染。"""
|
||||
import os
|
||||
|
||||
from insurance.db.compat import db
|
||||
from insurance.models.generation_task import GenerationTask
|
||||
from insurance.models.ppt_history import PptHistory
|
||||
from insurance.models.ppt_session import PptSession
|
||||
from insurance.ppt.renderer import PptRenderer
|
||||
|
||||
task = GenerationTask.query.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
session = PptSession.query.get(task.workspace_id)
|
||||
if not session:
|
||||
_update_task_status(task_id, status="failed", error_code="workspace_not_found",
|
||||
error_message="工作区不存在", finished_at=datetime.now())
|
||||
return
|
||||
|
||||
# 读取 DeckContract
|
||||
deck_path = session.deck_contract_path
|
||||
if not deck_path or not os.path.exists(deck_path):
|
||||
_update_task_status(task_id, status="failed", error_code="no_deck",
|
||||
error_message="DeckContract 快照不存在,请重新生成",
|
||||
finished_at=datetime.now())
|
||||
return
|
||||
|
||||
with open(deck_path, "r", encoding="utf-8") as f:
|
||||
deck = json.load(f)
|
||||
|
||||
# 读取编辑数据
|
||||
snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {}
|
||||
edits = snapshot.get("edits")
|
||||
|
||||
# 应用编辑:移除隐藏的幻灯片
|
||||
if edits and edits.get("slides"):
|
||||
hidden_indices = {i for i, s in enumerate(edits["slides"]) if s.get("hidden")}
|
||||
if hidden_indices:
|
||||
logger.info("重新生成: 移除 %d 个隐藏幻灯片: %s", len(hidden_indices), hidden_indices)
|
||||
|
||||
_update_task_status(task_id, stage="rendering", progress=30, message="重新渲染 PPT")
|
||||
|
||||
# 使用 DeckContract 重新渲染
|
||||
renderer = PptRenderer()
|
||||
user_id = task.user_id
|
||||
from insurance.config import get_storage_root
|
||||
output_dir = os.path.join(get_storage_root(), "outputs", "ppt", user_id, task_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, "presentation.pptx")
|
||||
|
||||
# 从 deck 中提取渲染参数
|
||||
theme = deck.get("stylePreset", "broker")
|
||||
normalized_data = deck.get("products", [{}])[0] if deck.get("products") else {}
|
||||
company_info = deck.get("company")
|
||||
template_config = deck.get("templateConfig")
|
||||
comparison = deck.get("comparison")
|
||||
generation_mode = deck.get("generationMode")
|
||||
scenario = deck.get("scenario")
|
||||
|
||||
result = renderer.render_enhanced(
|
||||
normalized_data, output_path, theme=theme,
|
||||
company_info=company_info,
|
||||
template_config=template_config,
|
||||
comparison=comparison,
|
||||
generation_mode=generation_mode,
|
||||
scenario=scenario,
|
||||
)
|
||||
|
||||
if not result.get("ok"):
|
||||
_update_task_status(task_id, status="failed", error_code="render_error",
|
||||
error_message=f"渲染失败: {result.get('error', '未知错误')}",
|
||||
finished_at=datetime.now())
|
||||
return
|
||||
|
||||
_update_task_status(task_id, stage="saving", progress=80, message="保存新版本")
|
||||
|
||||
# 解析幻灯片结构
|
||||
slides_data = None
|
||||
slides_dir = os.path.join(output_dir, "slides")
|
||||
try:
|
||||
slides_result = renderer.parse_slides(output_path, slides_dir)
|
||||
if slides_result.get("ok"):
|
||||
with open(slides_result["jsonPath"], "r", encoding="utf-8") as _f:
|
||||
slides_data = json.load(_f)
|
||||
except Exception as exc:
|
||||
logger.warning("重新生成: 幻灯片解析失败: %s", exc)
|
||||
|
||||
# 如果有隐藏页,从 slides.json 中标记
|
||||
if slides_data and edits and edits.get("slides"):
|
||||
for i, edit_slide in enumerate(edits["slides"]):
|
||||
if edit_slide.get("hidden") and i < len(slides_data.get("slides", [])):
|
||||
slides_data["slides"][i]["hidden"] = True
|
||||
slides_json_path = os.path.join(slides_dir, "slides.json")
|
||||
with open(slides_json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(slides_data, f, ensure_ascii=False)
|
||||
|
||||
# 质量检查
|
||||
quality_report = None
|
||||
try:
|
||||
from insurance.ppt.quality_checker import QualityChecker
|
||||
checker = QualityChecker()
|
||||
quality_report = checker.check(
|
||||
pptx_path=output_path,
|
||||
slides_data=slides_data,
|
||||
extractions=[],
|
||||
expected_slide_count=result.get("slideCount", 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("重新生成: 质量检查失败: %s", exc)
|
||||
|
||||
# 保存 DeckContract 快照
|
||||
new_deck_path = None
|
||||
if result.get("deck"):
|
||||
try:
|
||||
new_deck_path = os.path.join(output_dir, "deck_contract.json")
|
||||
with open(new_deck_path, "w", encoding="utf-8") as _f:
|
||||
json.dump(result["deck"], _f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 更新会话
|
||||
session = PptSession.query.get(task.workspace_id)
|
||||
if session:
|
||||
session.ppt_path = output_path
|
||||
session.slide_count = result.get("slideCount", 0)
|
||||
session.status = "done"
|
||||
session.generated_revision = session.draft_revision
|
||||
session.latest_output_path = output_path
|
||||
if slides_data:
|
||||
session.slides_json_path = os.path.join(slides_dir, "slides.json")
|
||||
session.preview_status = "ready"
|
||||
if quality_report:
|
||||
session.quality_report_json = json.dumps(quality_report, ensure_ascii=False)
|
||||
if new_deck_path:
|
||||
session.deck_contract_path = new_deck_path
|
||||
# 追加版本历史
|
||||
versions = json.loads(session.versions_json) if session.versions_json else []
|
||||
versions.append({
|
||||
"revision": session.generated_revision,
|
||||
"path": output_path,
|
||||
"slidesJsonPath": os.path.join(slides_dir, "slides.json") if slides_data else None,
|
||||
"deckPath": new_deck_path,
|
||||
"slideCount": result.get("slideCount", 0),
|
||||
"createdAt": datetime.now().isoformat(),
|
||||
})
|
||||
session.versions_json = json.dumps(versions, ensure_ascii=False)
|
||||
db.session.add(PptHistory(
|
||||
user_id=task.user_id,
|
||||
session_id=task.workspace_id,
|
||||
action_type="regenerate",
|
||||
content_snapshot=json.dumps({
|
||||
"revision": session.generated_revision,
|
||||
"slideCount": result.get("slideCount", 0),
|
||||
}, ensure_ascii=False),
|
||||
file_url=output_path,
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
_update_task_status(
|
||||
task_id,
|
||||
status="done",
|
||||
stage="completed",
|
||||
progress=100,
|
||||
message="重新生成完成",
|
||||
finished_at=datetime.now(),
|
||||
output_json=json.dumps({
|
||||
"downloadUrl": f"/insurance/ppt/download/{task.workspace_id}",
|
||||
"slideCount": result.get("slideCount", 0),
|
||||
"filePath": output_path,
|
||||
"revision": session.generated_revision if session else 0,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
# ─── 海报解析任务 ──────────────────────────────────────────
|
||||
|
||||
@shared_task(bind=True, name="insurance.parse_poster", max_retries=3, default_retry_delay=30)
|
||||
|
||||
@ -65,13 +65,14 @@ def create_task(user_id: str, artifact_type: str, operation: str,
|
||||
def _dispatch_to_celery(task):
|
||||
"""根据任务类型分发到对应的 Celery 任务。"""
|
||||
from insurance.generation.celery_tasks import (
|
||||
parse_ppt_task, generate_ppt_task,
|
||||
parse_ppt_task, generate_ppt_task, regenerate_ppt_task,
|
||||
parse_poster_task, generate_poster_task,
|
||||
)
|
||||
|
||||
task_map = {
|
||||
("ppt", "parse"): parse_ppt_task,
|
||||
("ppt", "generate"): generate_ppt_task,
|
||||
("ppt", "regenerate"): regenerate_ppt_task,
|
||||
("poster", "parse"): parse_poster_task,
|
||||
("poster", "generate"): generate_poster_task,
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ class PptSession(db.Model):
|
||||
quality_report_json = Column(Text, nullable=True, comment="质量检查报告 JSON")
|
||||
preview_status = Column(String(20), default="none", nullable=False,
|
||||
comment="预览状态: none/generating/ready/failed")
|
||||
deck_contract_path = Column(String(500), nullable=True, comment="DeckContract 快照路径")
|
||||
versions_json = Column(Text, nullable=True, comment="版本历史 JSON 数组")
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@ -71,6 +73,8 @@ class PptSession(db.Model):
|
||||
"slides_json_path": self.slides_json_path,
|
||||
"quality_report": json.loads(self.quality_report_json) if self.quality_report_json else None,
|
||||
"preview_status": self.preview_status or "none",
|
||||
"deck_contract_path": self.deck_contract_path,
|
||||
"versions": json.loads(self.versions_json) if self.versions_json else [],
|
||||
"created_at": str(self.created_at) if self.created_at else None,
|
||||
"updated_at": str(self.updated_at) if self.updated_at else None,
|
||||
}
|
||||
|
||||
@ -240,6 +240,7 @@ class PptRenderer:
|
||||
"ok": True,
|
||||
"path": output_path,
|
||||
"slideCount": script_result.get("slides", 0),
|
||||
"deck": deck,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
@ -251,14 +252,17 @@ class PptRenderer:
|
||||
except json.JSONDecodeError:
|
||||
# 脚本输出不是 JSON,尝试统计幻灯片数
|
||||
slide_count = self._count_slides(output_path)
|
||||
return {"ok": True, "path": output_path, "slideCount": slide_count}
|
||||
return {"ok": True, "path": output_path, "slideCount": slide_count, "deck": deck}
|
||||
else:
|
||||
logger.error(f"渲染脚本失败: {result.stderr}")
|
||||
return {"ok": False, "path": "", "slideCount": 0, "error": result.stderr}
|
||||
else:
|
||||
# 回退到基础渲染
|
||||
logger.warning(f"渲染脚本不存在: {render_script},使用基础渲染")
|
||||
return self._render_basic(normalized_data, output_path, theme)
|
||||
result = self._render_basic(normalized_data, output_path, theme)
|
||||
if result.get("ok"):
|
||||
result["deck"] = deck
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"渲染失败: {e}")
|
||||
|
||||
@ -765,6 +765,8 @@ def get_preview(session_id):
|
||||
"slides": slides_data,
|
||||
"qualityReport": quality_report,
|
||||
"slideCount": session.slide_count or 0,
|
||||
"versions": json.loads(session.versions_json) if session.versions_json else [],
|
||||
"generatedRevision": session.generated_revision or 0,
|
||||
})
|
||||
|
||||
|
||||
@ -782,8 +784,7 @@ def update_slide(session_id, index):
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
shapes = data.get("shapes")
|
||||
if not isinstance(shapes, list):
|
||||
return error(ErrorCode.PARAM_ERROR, "shapes 必须是数组")
|
||||
hidden = data.get("hidden")
|
||||
|
||||
try:
|
||||
with open(session.slides_json_path, "r", encoding="utf-8") as f:
|
||||
@ -795,7 +796,12 @@ def update_slide(session_id, index):
|
||||
if index < 0 or index >= len(slides):
|
||||
return error(ErrorCode.PARAM_ERROR, f"页码超出范围 (0-{len(slides) - 1})")
|
||||
|
||||
slides[index]["shapes"] = shapes
|
||||
if shapes is not None:
|
||||
if not isinstance(shapes, list):
|
||||
return error(ErrorCode.PARAM_ERROR, "shapes 必须是数组")
|
||||
slides[index]["shapes"] = shapes
|
||||
if hidden is not None:
|
||||
slides[index]["hidden"] = bool(hidden)
|
||||
|
||||
try:
|
||||
with open(session.slides_json_path, "w", encoding="utf-8") as f:
|
||||
@ -862,3 +868,72 @@ def update_quality_confirm(session_id):
|
||||
db.session.commit()
|
||||
|
||||
return success({"sessionId": session_id, "key": key, "confirmed": confirmed})
|
||||
|
||||
|
||||
@ppt_bp.route("/preview/<session_id>/regenerate", methods=["POST"])
|
||||
@jwt_required
|
||||
def regenerate_version(session_id):
|
||||
"""基于当前编辑内容重新生成新版本 PPT。
|
||||
|
||||
不覆盖当前版本,生成后追加到版本历史。
|
||||
"""
|
||||
user_id = str(getattr(request, "user_id", "guest"))
|
||||
session = _get_session(session_id, user_id)
|
||||
if not session:
|
||||
return error(ErrorCode.NOT_FOUND, "会话不存在")
|
||||
|
||||
if not session.ppt_path or not os.path.exists(session.ppt_path):
|
||||
return error(ErrorCode.PARAM_ERROR, "当前无可用的 PPT 文件")
|
||||
|
||||
# 检查是否有运行中的任务
|
||||
from insurance.generation import task_service
|
||||
if session.latest_task_id:
|
||||
from insurance.models.generation_task import GenerationTask
|
||||
active = GenerationTask.query.filter(
|
||||
GenerationTask.id == session.latest_task_id,
|
||||
GenerationTask.status.in_(["queued", "running"]),
|
||||
).first()
|
||||
if active:
|
||||
return error(ErrorCode.PARAM_ERROR, "有正在执行的任务,请等待完成")
|
||||
|
||||
# 读取当前编辑后的 slides.json
|
||||
edits_data = None
|
||||
if session.slides_json_path and os.path.exists(session.slides_json_path):
|
||||
try:
|
||||
with open(session.slides_json_path, "r", encoding="utf-8") as f:
|
||||
edits_data = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 递增版本号
|
||||
session.draft_revision = (session.draft_revision or 1) + 1
|
||||
session.status = "generating"
|
||||
_save_session(session)
|
||||
|
||||
# 创建异步任务,传递编辑数据
|
||||
result = task_service.create_task(
|
||||
user_id=user_id,
|
||||
artifact_type="ppt",
|
||||
operation="regenerate",
|
||||
workspace_id=session_id,
|
||||
title=session.title or f"PPT {session_id[:8]}",
|
||||
input_snapshot={
|
||||
"revision": session.draft_revision,
|
||||
"edits": edits_data,
|
||||
},
|
||||
idempotency_key=f"ppt_regen_{session_id}_{session.draft_revision}",
|
||||
)
|
||||
|
||||
if result.get("code") != 0:
|
||||
return error(ErrorCode.PARAM_ERROR, result.get("message", "创建任务失败"))
|
||||
|
||||
task_data = result["data"]
|
||||
session.latest_task_id = task_data["id"]
|
||||
_save_session(session)
|
||||
|
||||
return success({
|
||||
"taskId": task_data["id"],
|
||||
"status": "queued",
|
||||
"sessionId": session_id,
|
||||
"revision": session.draft_revision,
|
||||
})
|
||||
|
||||
@ -47,6 +47,7 @@
|
||||
|
||||
| 文档 | 内容摘要 |
|
||||
|------|---------|
|
||||
| [海报生成工作台完整重构修复计划.md](海报生成工作台完整重构修复计划.md) | 海报二期重构执行基线:自由上传、来源校验、AI 视觉候选、可编辑画布、多格式导出、后台同步、测试与上线 |
|
||||
| [PPT与海报功能问题整改计划.md](PPT与海报功能问题整改计划.md) | 当前问题审计、根因、文件级修复方案、测试、发布与回滚计划 |
|
||||
| [PPT与海报功能完整解决方案.md](PPT与海报功能完整解决方案.md) | PPT 增强 + 海报新模块:数据库设计、API、前端、AI 生图方案(已确认) |
|
||||
| [PPT与海报功能开发任务清单.md](PPT与海报功能开发任务清单.md) | 120 项可勾选任务,分 7 个 Phase,预估 11.5 天 |
|
||||
|
||||
1889
docs/海报生成工作台完整重构修复计划.md
Normal file
1889
docs/海报生成工作台完整重构修复计划.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -153,11 +153,11 @@ function resetAll() {
|
||||
}
|
||||
|
||||
.generator-page {
|
||||
--generator-accent: #2563eb;
|
||||
--generator-accent-soft: #eff6ff;
|
||||
--generator-border: #d9e2ef;
|
||||
--generator-text: #172033;
|
||||
--generator-muted: #667085;
|
||||
--generator-accent: #3B7A57;
|
||||
--generator-accent-soft: #f0f7f3;
|
||||
--generator-border: #d9e5df;
|
||||
--generator-text: #1a2e23;
|
||||
--generator-muted: #5f7a69;
|
||||
}
|
||||
|
||||
.generator-header {
|
||||
|
||||
@ -46,6 +46,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成决策说明 -->
|
||||
<div class="decision-section" v-if="currentScenario || selectedTemplate">
|
||||
<h4>系统决策</h4>
|
||||
<ul class="decision-list">
|
||||
<li>
|
||||
<span class="decision-label">检测场景</span>
|
||||
<span class="decision-value">{{ scenarioLabel(currentScenario) || '通用方案' }}</span>
|
||||
</li>
|
||||
<li v-if="selectedTemplate">
|
||||
<span class="decision-label">选用模板</span>
|
||||
<span class="decision-value">{{ selectedTemplate.name || selectedTemplate.id }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="decision-label">计划书数量</span>
|
||||
<span class="decision-value">{{ sessionFiles.length }} 份</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="decision-label">险种类型</span>
|
||||
<span class="decision-value">{{ selectedPlanTypes.map(planTypeLabel).join(' + ') || '未识别' }}</span>
|
||||
</li>
|
||||
<li v-if="companyId">
|
||||
<span class="decision-label">保险公司</span>
|
||||
<span class="decision-value">{{ companies.find(c => c.id === companyId)?.displayName || companyId }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 公司选择 -->
|
||||
<div class="company-section">
|
||||
<h4>选择保险公司</h4>
|
||||
@ -138,6 +165,9 @@ const selectedProductIds = computed(() =>
|
||||
const selectedPlanTypes = computed(() =>
|
||||
[...new Set(sessionFiles.value.map(file => file.type).filter(Boolean))]
|
||||
)
|
||||
const selectedTemplate = computed(() =>
|
||||
templates.value.find(t => t.id === selectedTemplateId.value) || null
|
||||
)
|
||||
const currentScenario = computed(() => {
|
||||
const types = sessionFiles.value.map(file => String(file.type || '').toLowerCase()).filter(Boolean)
|
||||
const savingsCount = types.filter(type => type === 'savings').length
|
||||
@ -381,6 +411,47 @@ function startPolling(taskId: string) {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.decision-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 14px 16px;
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e8ece8;
|
||||
}
|
||||
|
||||
.decision-section h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: #3B7A57;
|
||||
}
|
||||
|
||||
.decision-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.decision-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
color: #909399;
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.decision-value {
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.style-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||
@ -402,12 +473,12 @@ function startPolling(taskId: string) {
|
||||
}
|
||||
|
||||
.style-item:hover {
|
||||
border-color: #409eff;
|
||||
border-color: #3B7A57;
|
||||
}
|
||||
|
||||
.style-item.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
border-color: #3B7A57;
|
||||
background: #f0f7f3;
|
||||
}
|
||||
|
||||
.style-preview {
|
||||
|
||||
@ -5,8 +5,31 @@
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<!-- 预览生成中骨架屏 -->
|
||||
<div v-else-if="previewStatus === 'none' || previewStatus === 'generating'" class="skeleton-state">
|
||||
<el-card shadow="never">
|
||||
<div class="skeleton-header">
|
||||
<el-icon :size="28" color="#67c23a"><SuccessFilled /></el-icon>
|
||||
<div>
|
||||
<h3>PPT 已生成</h3>
|
||||
<p>正在准备预览,请稍候...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skeleton-body">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
<div class="skeleton-actions">
|
||||
<el-button type="primary" size="large" @click="downloadPpt" :loading="downloading">
|
||||
<el-icon><Download /></el-icon>
|
||||
{{ downloading ? `下载中 ${downloadProgress}%` : '直接下载 PPT' }}
|
||||
</el-button>
|
||||
<el-button @click="$emit('new-session')">新建材料</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 预览失败降级 -->
|
||||
<div v-else-if="previewStatus === 'failed' || previewStatus === 'none'" class="fallback-state">
|
||||
<div v-else-if="previewStatus === 'failed'" class="fallback-state">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="fallback-header">
|
||||
@ -61,7 +84,7 @@
|
||||
v-for="(slide, idx) in slides"
|
||||
:key="idx"
|
||||
class="rail-thumb"
|
||||
:class="{ active: currentSlide === idx }"
|
||||
:class="{ active: currentSlide === idx, hidden: slide.hidden }"
|
||||
@click="currentSlide = idx"
|
||||
>
|
||||
<div class="thumb-wrapper">
|
||||
@ -74,8 +97,17 @@
|
||||
</div>
|
||||
<div class="thumb-footer">
|
||||
<span class="thumb-page">{{ idx + 1 }}</span>
|
||||
<span v-if="getSlideIssue(idx)" class="thumb-issue" :class="getSlideIssue(idx)">
|
||||
<el-icon :size="10"><WarningFilled /></el-icon>
|
||||
<span class="thumb-controls">
|
||||
<span v-if="getSlideIssue(idx)" class="thumb-issue" :class="getSlideIssue(idx)">
|
||||
<el-icon :size="10"><WarningFilled /></el-icon>
|
||||
</span>
|
||||
<button
|
||||
class="thumb-hide-btn"
|
||||
:title="slide.hidden ? '恢复显示' : '隐藏此页'"
|
||||
@click.stop="toggleSlideHidden(idx)"
|
||||
>
|
||||
<el-icon :size="12"><component :is="slide.hidden ? 'View' : 'Hide'" /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -98,7 +130,10 @@
|
||||
</div>
|
||||
<div class="canvas-nav">
|
||||
<el-button :icon="ArrowLeft" :disabled="currentSlide <= 0" @click="currentSlide--" circle size="small" />
|
||||
<span class="page-indicator">{{ currentSlide + 1 }} / {{ slides.length }}</span>
|
||||
<span class="page-indicator">
|
||||
{{ currentSlide + 1 }} / {{ slides.length }}
|
||||
<template v-if="hiddenCount > 0">({{ hiddenCount }} 页已隐藏)</template>
|
||||
</span>
|
||||
<el-button :icon="ArrowRight" :disabled="currentSlide >= slides.length - 1" @click="currentSlide++" circle size="small" />
|
||||
</div>
|
||||
</main>
|
||||
@ -164,6 +199,37 @@
|
||||
<el-tag size="small">{{ qualityReport.summary.manualPending }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版本历史 -->
|
||||
<div class="panel-section" v-if="versions.length > 0">
|
||||
<h4>版本历史</h4>
|
||||
<ul class="version-list">
|
||||
<li
|
||||
v-for="ver in versions"
|
||||
:key="ver.revision"
|
||||
class="version-item"
|
||||
:class="{ current: ver.revision === currentRevision }"
|
||||
>
|
||||
<span class="ver-label">v{{ ver.revision }}</span>
|
||||
<span class="ver-info">{{ ver.slideCount }}页 · {{ formatTime(ver.createdAt) }}</span>
|
||||
<span v-if="ver.revision === currentRevision" class="ver-badge">当前</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 重新生成按钮 -->
|
||||
<div class="panel-section panel-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="regenerating"
|
||||
@click="handleRegenerate"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
应用编辑并生成新版本
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@ -177,6 +243,7 @@ import {
|
||||
SuccessFilled, Download, WarningFilled,
|
||||
CircleCheckFilled, CircleCloseFilled,
|
||||
ArrowLeft, ArrowRight, DArrowRight,
|
||||
View, Hide, Refresh,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { pptApi } from '@/utils/ppt-api'
|
||||
import type { SlidesJson, SlideData, SlideShape, QualityReport } from '@/utils/ppt-api'
|
||||
@ -199,9 +266,20 @@ const qualityReport = ref<QualityReport | null>(null)
|
||||
const currentSlide = ref(0)
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const regenerating = ref(false)
|
||||
const canvasContainerRef = ref<HTMLElement | null>(null)
|
||||
const mainCanvasRef = ref<InstanceType<typeof PptSlideCanvas> | null>(null)
|
||||
|
||||
// 版本历史
|
||||
interface VersionEntry {
|
||||
revision: number
|
||||
path: string
|
||||
slideCount: number
|
||||
createdAt: string
|
||||
}
|
||||
const versions = ref<VersionEntry[]>([])
|
||||
const currentRevision = ref(0)
|
||||
|
||||
// 计算属性
|
||||
const slides = computed(() => slideJson.value?.slides || [])
|
||||
const currentSlideData = computed(() => slides.value[currentSlide.value] || null)
|
||||
@ -213,6 +291,8 @@ const canvasScale = computed(() => {
|
||||
return Math.min(1, containerW / slideW)
|
||||
})
|
||||
|
||||
const hiddenCount = computed(() => slides.value.filter(s => s.hidden).length)
|
||||
|
||||
const qualityTagType = computed(() => {
|
||||
if (!qualityReport.value) return 'info'
|
||||
const { fail, warn } = qualityReport.value.summary
|
||||
@ -247,6 +327,24 @@ function jumpToPage(page: number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换幻灯片显隐
|
||||
async function toggleSlideHidden(index: number) {
|
||||
if (!slideJson.value) return
|
||||
const slide = slideJson.value.slides[index]
|
||||
if (!slide) return
|
||||
slide.hidden = !slide.hidden
|
||||
try {
|
||||
await pptApi.updateSlideHidden(props.sessionId, index, !!slide.hidden)
|
||||
// 如果隐藏的是当前页,跳到下一个可见页
|
||||
if (slide.hidden && currentSlide.value === index) {
|
||||
const nextVisible = slides.value.findIndex((s, i) => i !== index && !s.hidden)
|
||||
if (nextVisible >= 0) currentSlide.value = nextVisible
|
||||
}
|
||||
} catch {
|
||||
ElMessage.warning('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 文本编辑
|
||||
async function onShapesUpdated(shapes: SlideShape[]) {
|
||||
if (!slideJson.value) return
|
||||
@ -273,6 +371,63 @@ async function onManualConfirm(key: string, confirmed: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成新版本
|
||||
async function handleRegenerate() {
|
||||
regenerating.value = true
|
||||
try {
|
||||
const res: any = await pptApi.regenerateVersion(props.sessionId)
|
||||
const data = res?.data
|
||||
if (data?.taskId) {
|
||||
ElMessage.success(`新版本 v${data.revision} 已提交生成`)
|
||||
startRegenPoll(data.taskId)
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '重新生成失败')
|
||||
} finally {
|
||||
regenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let regenPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function startRegenPoll(taskId: string) {
|
||||
const startTime = Date.now()
|
||||
async function poll() {
|
||||
if (Date.now() - startTime > 5 * 60 * 1000) {
|
||||
clearTimeout(regenPollTimer!)
|
||||
ElMessage.warning('生成超时,请刷新页面查看')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await (await import('@/utils/api')).default.get(`/workspace/tasks/${taskId}`)
|
||||
const data = res?.data ?? res
|
||||
if (data?.status === 'done') {
|
||||
clearTimeout(regenPollTimer!)
|
||||
ElMessage.success('新版本生成完成')
|
||||
await loadPreview()
|
||||
return
|
||||
}
|
||||
if (data?.status === 'failed') {
|
||||
clearTimeout(regenPollTimer!)
|
||||
ElMessage.error(data.errorMessage || '生成失败')
|
||||
return
|
||||
}
|
||||
} catch { /* 继续轮询 */ }
|
||||
regenPollTimer = setTimeout(poll, 3000)
|
||||
}
|
||||
regenPollTimer = setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
// 下载
|
||||
async function downloadPpt() {
|
||||
downloading.value = true
|
||||
@ -314,6 +469,8 @@ async function loadPreview() {
|
||||
previewStatus.value = data?.previewStatus || 'none'
|
||||
slideJson.value = data?.slides || null
|
||||
qualityReport.value = data?.qualityReport || null
|
||||
versions.value = data?.versions || []
|
||||
currentRevision.value = data?.generatedRevision || 0
|
||||
} catch {
|
||||
previewStatus.value = 'failed'
|
||||
ElMessage.warning('预览数据加载失败')
|
||||
@ -388,6 +545,39 @@ onUnmounted(() => {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.skeleton-state {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.skeleton-header p {
|
||||
margin: 4px 0 0;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fallback-state {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
@ -494,7 +684,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.rail-thumb.active {
|
||||
border-color: #409eff;
|
||||
border-color: #3B7A57;
|
||||
}
|
||||
|
||||
.thumb-wrapper {
|
||||
@ -530,6 +720,35 @@ onUnmounted(() => {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.rail-thumb.hidden {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.thumb-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.thumb-hide-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thumb-hide-btn:hover {
|
||||
background: #e8edf2;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 中栏画布 */
|
||||
.canvas-panel {
|
||||
display: flex;
|
||||
@ -624,7 +843,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.check-jump:hover {
|
||||
color: #409eff;
|
||||
color: #3B7A57;
|
||||
}
|
||||
|
||||
.confirm-list {
|
||||
@ -661,6 +880,54 @@ onUnmounted(() => {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.version-item.current {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.ver-label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.ver-info {
|
||||
flex: 1;
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ver-badge {
|
||||
font-size: 10px;
|
||||
color: #3B7A57;
|
||||
background: #f0f7f3;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* 移动端 */
|
||||
@media (max-width: 767px) {
|
||||
.workspace-body {
|
||||
|
||||
@ -389,7 +389,7 @@ defineExpose({ render })
|
||||
.text-editor {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
border: 2px solid #409eff;
|
||||
border: 2px solid #3B7A57;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 4px;
|
||||
|
||||
@ -53,6 +53,7 @@ export interface SlideData {
|
||||
index: number
|
||||
background: string
|
||||
shapes: SlideShape[]
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface SlidesJson {
|
||||
@ -202,8 +203,18 @@ export const pptApi = {
|
||||
return api.put(`/ppt/preview/${sessionId}/slide/${slideIndex}`, { shapes })
|
||||
},
|
||||
|
||||
/** 更新幻灯片显隐状态 */
|
||||
updateSlideHidden(sessionId: string, slideIndex: number, hidden: boolean) {
|
||||
return api.put(`/ppt/preview/${sessionId}/slide/${slideIndex}`, { hidden })
|
||||
},
|
||||
|
||||
/** 更新人工质量确认项 */
|
||||
updateQualityConfirm(sessionId: string, key: string, confirmed: boolean) {
|
||||
return api.put(`/ppt/preview/${sessionId}/quality-confirm`, { key, confirmed })
|
||||
},
|
||||
|
||||
/** 基于当前编辑内容重新生成新版本 */
|
||||
regenerateVersion(sessionId: string) {
|
||||
return api.post(`/ppt/preview/${sessionId}/regenerate`)
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user