baodan/frontend/src/pages/components/ppt/PptSlideCanvas.vue

483 lines
13 KiB
Vue
Raw Normal View History

<template>
<div class="slide-canvas-wrapper" :style="wrapperStyle">
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
class="slide-canvas"
所有阶段 3-5 的任务全部完成。最终盘点: 全部改动文件总览 后端(8 个文件) 文件 操作 api/insurance/models/ppt_session.py 修改 — 新增 5 个字段 api/insurance/db/migrate_025.py 新建 — 数据库迁移 api/insurance/ppt/renderer.py 修改 — 新增 parse_slides(),deck 返回 api/insurance/ppt/quality_checker.py 新建 — 质量检查 api/insurance/ppt/routes.py 修改 — 新增 5 个接口 api/insurance/generation/celery_tasks.py 修改 — 集成预览+质检+版本化,新增 regenerate 任务 api/insurance/generation/task_service.py 修改 — 注册 regenerate 任务 前端(8 个文件) 文件 操作 frontend/src/utils/ppt-api.ts 修改 — 新增类型和 API 方法 frontend/src/pages/components/ppt/PptSlideCanvas.vue 新建 — Canvas 渲染器 frontend/src/pages/components/ppt/PptResult.vue 重写 — 三栏工作台 + 版本历史 frontend/src/pages/components/ppt/PptUpload.vue 重写 — 双栏 Hero 布局 frontend/src/pages/components/ppt/PptParsing.vue 重写 — 五阶段进度 frontend/src/pages/components/ppt/PptDataReview.vue 修改 — 主题色更新 frontend/src/pages/PptPage.vue 修改 — 步骤描述 + 主题色 frontend/src/pages/PptHistoryPage.vue 重写 — 卡片式布局 新增后端接口 方法 路径 功能 GET /ppt/preview/:id 获取幻灯片 JSON + 质量报告 + 版本历史 PUT /ppt/preview/:id/slide/:index 更新编辑内容 + 显隐 PUT /ppt/preview/:id/quality-confirm 人工质量确认 POST /ppt/preview/:id/regenerate 基于编辑生成新版本
2026-07-29 22:14:02 +08:00
role="img"
:aria-label="`幻灯片 ${(slide?.index ?? 0) + 1}`"
@click="handleClick"
@dblclick="handleDoubleClick"
@mousemove="handleMouseMove"
/>
<!-- 行内文字编辑器 -->
<div
v-if="editingShape || editingCell"
class="text-editor-overlay"
:style="editorStyle"
>
<textarea
ref="editorRef"
v-model="editingText"
class="text-editor"
:style="editorTextStyle"
@blur="commitEdit"
@keydown.escape="cancelEdit"
@keydown.ctrl.enter="commitEdit"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import type { SlideData, SlideShape, SlideParagraph } from '@/utils/ppt-api'
const props = defineProps<{
slide: SlideData | null
slideWidth?: number
slideHeight?: number
scale?: number
editable?: boolean
selectedShapeId?: string | null
}>()
const emit = defineEmits<{
'update:shapes': [shapes: SlideShape[]]
'shape-select': [shape: SlideShape | null, index: number]
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const editorRef = ref<HTMLTextAreaElement | null>(null)
// 编辑状态
const editingShape = ref<SlideShape | null>(null)
const editingText = ref('')
const editingShapeIndex = ref(-1)
const editingCell = ref<{ shape: SlideShape; shapeIndex: number; row: number; col: number } | null>(null)
// 画布尺寸
已按计划书完成本轮核心 P0/P1 修复,覆盖用户反馈的三个主要问题。 主要结果: PPT: 已完成步骤可自由返回,刷新后保留当前步骤。 结果页改为固定三栏视口,缩略图和质量面板独立滚动。 修复画布宽度计算和宽高自适应缩放。 返回配置页时恢复上次模板。 Worker 禁止静默替换模板,不兼容时明确失败。 结果页显示实际应用的模板。 海报解析与合规: 修复 insured/policy 嵌套字段映射。 增加 partial/failed 解析质量判断和缺失字段提示。 传入险种、产品、保司和别名上下文。 修复性别、币种归一化和错误字段计数。 新增服务端字符级合规接口与生成前复检。 不合规文字可高亮、点击定位并选中对应文字。 海报编辑: AI 图片现在作为背景资产,不再替换整个海报。 单图、长图生成后仍可编辑标题、正文、CTA、数据和卖点。 背景生成失败不会清空当前编辑内容。 下载时合成背景、文字、数据、图表和免责声明。 增加可编辑文档快照、最终 PNG 保存和历史继续编辑。 新增数据库迁移:[migrate_028.py](/D:/work/code/python/coding/baodanagent/api/insurance/db/migrate_028.py) 验证结果: 专项及相关后端测试:26 passed, 1 skipped Python 全模块编译检查:通过 前端生产构建:通过 git diff --check:通过 全量测试收集受本机缺少 python-pptx 依赖影响,报错为 ModuleNotFoundError: pptx,不是本次修改产生的测试失败。
2026-07-31 15:20:37 +08:00
const baseWidth = computed(() => props.slideWidth || 960)
const baseHeight = computed(() => props.slideHeight || 540)
const scale = computed(() => props.scale ?? 1)
const canvasWidth = computed(() => Math.round(baseWidth.value * scale.value))
const canvasHeight = computed(() => Math.round(baseHeight.value * scale.value))
const wrapperStyle = computed(() => ({
width: `${canvasWidth.value}px`,
height: `${canvasHeight.value}px`,
position: 'relative' as const,
}))
// 编辑器定位
const editorStyle = computed(() => {
if (editingCell.value) {
const { shape, row, col } = editingCell.value
const rows = shape.rows || []
const cellH = Math.max(20, shape.h / Math.max(rows.length, 1))
const cellW = shape.w / Math.max(rows[0]?.length || 1, 1)
return {
left: `${(shape.x + col * cellW) * scale.value}px`,
top: `${(shape.y + row * cellH) * scale.value}px`,
width: `${cellW * scale.value}px`,
minHeight: `${cellH * scale.value}px`,
}
}
if (!editingShape.value) return {}
const s = editingShape.value
return {
left: `${s.x * scale.value}px`,
top: `${s.y * scale.value}px`,
width: `${s.w * scale.value}px`,
minHeight: `${s.h * scale.value}px`,
}
})
const editorTextStyle = computed(() => {
if (editingCell.value) {
const cell = editingCell.value.shape.rows?.[editingCell.value.row]?.[editingCell.value.col]
return {
fontSize: `${(cell?.fontSize || 12) * scale.value}px`,
fontWeight: cell?.bold ? 'bold' : 'normal',
}
}
if (!editingShape.value?.paragraphs?.length) return {}
const p = editingShape.value.paragraphs[0]
return {
fontSize: `${(p.fontSize || 14) * scale.value}px`,
fontWeight: p.bold ? 'bold' : 'normal',
color: p.color || '#333',
textAlign: p.align || 'left',
}
})
// 渲染
function render() {
const canvas = canvasRef.value
if (!canvas || !props.slide) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const s = scale.value
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
ctx.scale(s, s)
// 背景
const bg = props.slide.background || '#ffffff'
ctx.fillStyle = bg
ctx.fillRect(0, 0, baseWidth.value, baseHeight.value)
// 形状
for (const shape of props.slide.shapes) {
renderShape(ctx, shape)
}
const selected = props.slide.shapes.find(shape => shape.id === props.selectedShapeId)
if (selected) {
ctx.strokeStyle = '#3B7A57'
ctx.lineWidth = 2 / s
ctx.setLineDash([6 / s, 4 / s])
ctx.strokeRect(selected.x, selected.y, selected.w, selected.h)
ctx.setLineDash([])
}
ctx.restore()
}
function renderShape(ctx: CanvasRenderingContext2D, shape: SlideShape) {
if (shape.type === 'group' && shape.children) {
for (const child of shape.children) {
renderShape(ctx, child)
}
return
}
// 填充背景
if (shape.fill) {
ctx.fillStyle = shape.fill
ctx.fillRect(shape.x, shape.y, shape.w, shape.h)
}
// 边框
if (shape.lineColor) {
ctx.strokeStyle = shape.lineColor
ctx.lineWidth = shape.lineWidth || 1
ctx.strokeRect(shape.x, shape.y, shape.w, shape.h)
}
// 文本
if (shape.type === 'textbox' && shape.paragraphs) {
renderText(ctx, shape)
}
// 表格
if (shape.type === 'table' && shape.rows) {
renderTable(ctx, shape)
}
// 图片占位
if (shape.type === 'image') {
renderImagePlaceholder(ctx, shape)
}
}
function renderText(ctx: CanvasRenderingContext2D, shape: SlideShape) {
const paragraphs = shape.paragraphs || []
let y = shape.y + 4 // 顶部内边距
const maxWidth = shape.w - 8
for (const para of paragraphs) {
if (!para.text) {
y += (para.fontSize || 14) * 1.4
continue
}
const fontSize = para.fontSize || 14
const fontWeight = para.bold ? 'bold' : 'normal'
const color = para.color || '#333333'
const align = para.align || 'left'
ctx.font = `${fontWeight} ${fontSize}px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`
ctx.fillStyle = color
ctx.textBaseline = 'top'
// 自动换行
const lineHeight = fontSize * 1.4
const words = para.text.split('')
let line = ''
let lineX = shape.x + 4
for (const char of words) {
const testLine = line + char
const metrics = ctx.measureText(testLine)
if (metrics.width > maxWidth && line) {
// 绘制当前行
let drawX = lineX
if (align === 'center') {
drawX = shape.x + (shape.w - ctx.measureText(line).width) / 2
} else if (align === 'right') {
drawX = shape.x + shape.w - 4 - ctx.measureText(line).width
}
ctx.fillText(line, drawX, y)
line = char
y += lineHeight
} else {
line = testLine
}
}
if (line) {
let drawX = lineX
if (align === 'center') {
drawX = shape.x + (shape.w - ctx.measureText(line).width) / 2
} else if (align === 'right') {
drawX = shape.x + shape.w - 4 - ctx.measureText(line).width
}
ctx.fillText(line, drawX, y)
y += lineHeight
}
}
}
function renderTable(ctx: CanvasRenderingContext2D, shape: SlideShape) {
const rows = shape.rows || []
if (rows.length === 0) return
const cellH = Math.max(20, shape.h / rows.length)
const cellW = shape.w / (rows[0]?.length || 1)
for (let r = 0; r < rows.length; r++) {
for (let c = 0; c < rows[r].length; c++) {
const cell = rows[r][c]
const cx = shape.x + c * cellW
const cy = shape.y + r * cellH
// 单元格背景(表头行深色)
ctx.fillStyle = r === 0 ? '#f0f4f8' : '#ffffff'
ctx.fillRect(cx, cy, cellW, cellH)
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 0.5
ctx.strokeRect(cx, cy, cellW, cellH)
// 文本
const fontSize = cell.fontSize || 12
ctx.font = `${cell.bold ? 'bold' : 'normal'} ${fontSize}px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`
ctx.fillStyle = '#333'
ctx.textBaseline = 'middle'
ctx.fillText(cell.text || '', cx + 4, cy + cellH / 2)
}
}
}
function renderImagePlaceholder(ctx: CanvasRenderingContext2D, shape: SlideShape) {
ctx.fillStyle = '#e8edf2'
ctx.fillRect(shape.x, shape.y, shape.w, shape.h)
ctx.strokeStyle = '#c0c8d4'
ctx.lineWidth = 1
ctx.strokeRect(shape.x, shape.y, shape.w, shape.h)
// 图片图标
const cx = shape.x + shape.w / 2
const cy = shape.y + shape.h / 2
const iconSize = Math.min(shape.w, shape.h) * 0.3
ctx.fillStyle = '#a0aec0'
ctx.beginPath()
ctx.moveTo(cx - iconSize * 0.5, cy + iconSize * 0.3)
ctx.lineTo(cx - iconSize * 0.2, cy - iconSize * 0.1)
ctx.lineTo(cx + iconSize * 0.1, cy + iconSize * 0.2)
ctx.lineTo(cx + iconSize * 0.3, cy - iconSize * 0.2)
ctx.lineTo(cx + iconSize * 0.5, cy + iconSize * 0.3)
ctx.closePath()
ctx.fill()
ctx.font = '11px sans-serif'
ctx.fillStyle = '#8899aa'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
ctx.fillText('图片', cx, cy + iconSize * 0.5)
ctx.textAlign = 'left'
}
// 点击处理
function handleClick(e: MouseEvent) {
if (!props.editable || !props.slide) return
const rect = canvasRef.value!.getBoundingClientRect()
const x = (e.clientX - rect.left) / scale.value
const y = (e.clientY - rect.top) / scale.value
const hit = findShapeAt(x, y)
emit('shape-select', hit?.shape || null, hit?.index ?? -1)
}
function handleDoubleClick(e: MouseEvent) {
if (!props.editable || !props.slide) return
const rect = canvasRef.value!.getBoundingClientRect()
const x = (e.clientX - rect.left) / scale.value
const y = (e.clientY - rect.top) / scale.value
const hit = findShapeAt(x, y)
if (!hit) return
emit('shape-select', hit.shape, hit.index)
if (hit.shape.type === 'textbox') {
startEdit(hit.shape, hit.index)
} else if (hit.shape.type === 'table') {
startCellEdit(hit.shape, hit.index, x, y)
}
}
function findShapeAt(x: number, y: number) {
const shapes = props.slide?.shapes || []
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i]
if (x >= shape.x && x <= shape.x + shape.w &&
y >= shape.y && y <= shape.y + shape.h) {
return { shape, index: i }
}
}
return null
}
function startEdit(shape: SlideShape, index: number) {
editingCell.value = null
editingShape.value = JSON.parse(JSON.stringify(shape))
editingShapeIndex.value = index
editingText.value = (shape.paragraphs || []).map(p => p.text).join('\n')
nextTick(() => {
editorRef.value?.focus()
editorRef.value?.select()
})
}
function startCellEdit(shape: SlideShape, shapeIndex: number, x: number, y: number) {
const cloned: SlideShape = JSON.parse(JSON.stringify(shape))
const rows = cloned.rows || []
if (!rows.length) return
const row = Math.min(rows.length - 1, Math.floor((y - cloned.y) / Math.max(20, cloned.h / rows.length)))
const colCount = rows[row]?.length || 1
const col = Math.min(colCount - 1, Math.floor((x - cloned.x) / (cloned.w / colCount)))
editingShape.value = null
editingCell.value = { shape: cloned, shapeIndex, row, col }
editingText.value = rows[row]?.[col]?.text || ''
nextTick(() => {
editorRef.value?.focus()
editorRef.value?.select()
})
}
function commitEdit() {
if (editingCell.value && props.slide) {
const { shape, shapeIndex, row, col } = editingCell.value
if (shape.rows?.[row]?.[col]) {
shape.rows[row][col] = { ...shape.rows[row][col], text: editingText.value }
const updatedShapes = [...props.slide.shapes]
updatedShapes[shapeIndex] = shape
emit('update:shapes', updatedShapes)
}
editingCell.value = null
render()
return
}
if (!editingShape.value || !props.slide) return
const text = editingText.value
const lines = text.split('\n')
const originalParas = editingShape.value.paragraphs || []
// 更新段落文本,保留原格式
const updatedParas = lines.map((line, i) => {
const base = originalParas[i] || originalParas[originalParas.length - 1] || {
fontSize: 14, bold: false, color: '#333333', align: 'left' as const,
}
return { ...base, text: line }
})
editingShape.value.paragraphs = updatedParas
// 通知父组件
const updatedShapes = [...props.slide!.shapes]
updatedShapes[editingShapeIndex.value] = { ...editingShape.value }
emit('update:shapes', updatedShapes)
editingShape.value = null
editingShapeIndex.value = -1
render()
}
function cancelEdit() {
editingShape.value = null
editingCell.value = null
editingShapeIndex.value = -1
}
// hover 光标
function handleMouseMove(e: MouseEvent) {
if (!props.editable || !canvasRef.value) return
const rect = canvasRef.value.getBoundingClientRect()
const x = (e.clientX - rect.left) / scale.value
const y = (e.clientY - rect.top) / scale.value
let isEditable = false
if (props.slide) {
for (const shape of props.slide.shapes) {
if (x >= shape.x && x <= shape.x + shape.w &&
y >= shape.y && y <= shape.y + shape.h) {
isEditable = ['textbox', 'table', 'image'].includes(shape.type)
break
}
}
}
canvasRef.value.style.cursor = isEditable ? 'pointer' : 'default'
}
// 监听数据变化重新渲染
watch(() => props.slide, () => {
nextTick(render)
}, { deep: true })
watch(scale, () => {
nextTick(render)
})
watch(() => props.selectedShapeId, () => nextTick(render))
onMounted(() => {
render()
})
defineExpose({ render })
</script>
<style scoped>
.slide-canvas-wrapper {
position: relative;
overflow: hidden;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.slide-canvas {
display: block;
width: 100%;
height: 100%;
}
.text-editor-overlay {
position: absolute;
z-index: 10;
pointer-events: auto;
}
.text-editor {
width: 100%;
min-height: 100%;
2026-07-29 21:41:28 +08:00
border: 2px solid #3B7A57;
border-radius: 2px;
background: rgba(255, 255, 255, 0.95);
padding: 4px;
outline: none;
resize: vertical;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.4;
}
</style>