阶段 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:
wsb1224 2026-07-29 21:41:28 +08:00
parent f23b08eec6
commit 25c6f2a3b7
13 changed files with 2570 additions and 22 deletions

View File

@ -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():

View File

@ -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)

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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}")

View File

@ -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,
})

View File

@ -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 天 |

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -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`)
},
}