本轮新增:

PPT 对象选择和绿色选中框。
双击编辑文本框、表格单元格。
文字字体、字号、加粗、颜色、对齐属性。
Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z 撤销重做。
PPT 缩略图/网格总览切换。
历史版本只读查看。
历史版本“恢复为新版本”,不覆盖旧文件。
海报区块显示/隐藏。
长图区块上移、下移排序。
新背景候选确认,可选择使用新背景或保留原背景。
合规问题“一键采用建议”。
新增 warn 合规级别。
解析失败人工填写入口。
展示解析方法和低质量页诊断。
同步更新了[API 接口文档](/D:/work/code/python/coding/baodanagent/docs/保险智能客服系统_API接口文档.md)。
验证结果:
相关后端测试:28 passed, 1 skipped
Python 编译检查:通过
前端生产构建:通过
git diff --check:通过
This commit is contained in:
wsb1224 2026-07-31 15:41:58 +08:00
parent cb09ed4803
commit cf77d40569
18 changed files with 892 additions and 109 deletions

View File

@ -670,16 +670,20 @@ def _apply_edits_to_pptx(pptx_path: str, edit_slides: list):
slide = list(prs.slides)[slide_idx]
edit_shapes = edit_slide.get("shapes", [])
for edit_shape in edit_shapes:
if edit_shape.get("type") != "textbox":
shape_type = edit_shape.get("type")
if shape_type not in ("textbox", "table"):
continue
edit_paras = edit_shape.get("paragraphs", [])
if not edit_paras:
edit_rows = edit_shape.get("rows", [])
if not edit_paras and not edit_rows:
continue
edit_x = edit_shape.get("x", 0)
edit_y = edit_shape.get("y", 0)
# 匹配 PPTX 中的形状(按位置)
for shape in slide.shapes:
if not shape.has_text_frame:
if shape_type == "textbox" and not shape.has_text_frame:
continue
if shape_type == "table" and not shape.has_table:
continue
shape_x = int(shape.left) if shape.left else 0
shape_y = int(shape.top) if shape.top else 0
@ -687,8 +691,10 @@ def _apply_edits_to_pptx(pptx_path: str, edit_slides: list):
continue
if abs(shape_y - _px_to_emu(edit_y)) > TOLERANCE:
continue
# 位置匹配,更新文字
_update_shape_text(shape, edit_paras)
if shape_type == "table":
_update_table_text(shape, edit_rows)
else:
_update_shape_text(shape, edit_paras)
edited_count += 1
break
@ -707,6 +713,7 @@ def _update_shape_text(shape, edit_paras: list):
"""将编辑后的段落文本写入 python-pptx 形状。"""
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
tf = shape.text_frame
existing_paras = list(tf.paragraphs)
@ -718,15 +725,39 @@ def _update_shape_text(shape, edit_paras: list):
# 保留第一个 run 的格式,替换文本
if para.runs:
para.runs[0].text = new_text
first_run = para.runs[0]
# 删除多余 runs
for run in para.runs[1:]:
run.text = ""
else:
para.text = new_text
first_run = para.runs[0] if para.runs else None
else:
# 新增段落
para = tf.add_paragraph()
para.text = new_text
first_run = para.runs[0] if para.runs else None
align_map = {
"left": PP_ALIGN.LEFT,
"center": PP_ALIGN.CENTER,
"right": PP_ALIGN.RIGHT,
"justify": PP_ALIGN.JUSTIFY,
}
if edit_para.get("align") in align_map:
para.alignment = align_map[edit_para["align"]]
if first_run:
if edit_para.get("fontSize"):
first_run.font.size = Pt(float(edit_para["fontSize"]))
if edit_para.get("fontFamily"):
first_run.font.name = str(edit_para["fontFamily"])
first_run.font.bold = bool(edit_para.get("bold"))
color = str(edit_para.get("color") or "").lstrip("#")
if len(color) == 6:
try:
first_run.font.color.rgb = RGBColor.from_string(color.upper())
except ValueError:
pass
# 删除多余段落(如果编辑后段落变少了)
# python-pptx 不支持直接删除段落,只能清空
@ -734,6 +765,26 @@ def _update_shape_text(shape, edit_paras: list):
existing_paras[i].text = ""
def _update_table_text(shape, edit_rows: list):
"""将编辑后的表格单元格写回 PPTX。"""
from pptx.util import Pt
for row_index, edit_row in enumerate(edit_rows):
if row_index >= len(shape.table.rows):
break
row = shape.table.rows[row_index]
for col_index, edit_cell in enumerate(edit_row):
if col_index >= len(row.cells):
break
cell = row.cells[col_index]
cell.text = str(edit_cell.get("text") or "")
for para in cell.text_frame.paragraphs:
for run in para.runs:
run.font.bold = bool(edit_cell.get("bold"))
if edit_cell.get("fontSize"):
run.font.size = Pt(float(edit_cell["fontSize"]))
@shared_task(bind=True, name="insurance.regenerate_ppt", max_retries=3, default_retry_delay=30)
def regenerate_ppt_task(self, task_id: str):
"""PPT 重新生成任务(基于编辑内容生成新版本)。"""

View File

@ -10,6 +10,7 @@ RULES = (
"severity": "block",
"message": "不得使用确定性收益承诺",
"suggestion": "改为“利益以正式计划书为准”",
"replacement": "相关利益",
},
{
"ruleId": "RISK_FREE_CLAIM",
@ -17,6 +18,7 @@ RULES = (
"severity": "block",
"message": "不得宣称保险或投资安排不存在风险",
"suggestion": "删除绝对化表述,并补充必要的风险提示",
"replacement": "存在一定风险",
},
{
"ruleId": "EXAGGERATED_RETURN",
@ -24,6 +26,15 @@ RULES = (
"severity": "block",
"message": "收益表述必须有正式计划书依据且不得夸大",
"suggestion": "引用已确认的计划书数据并注明非保证利益",
"replacement": "演示利益",
},
{
"ruleId": "UNSUPPORTED_SUPERLATIVE",
"terms": ("行业领先", "最佳"),
"severity": "warn",
"message": "比较性或最高级表述需要可核验依据",
"suggestion": "改为客观描述产品特点,或补充权威依据",
"replacement": "具有特色",
},
)
@ -61,6 +72,7 @@ def check_copy_compliance(copy_content: dict) -> dict:
"text": term,
"message": rule["message"],
"suggestion": rule["suggestion"],
"replacement": rule["replacement"],
})
start = index + len(term)

View File

@ -273,6 +273,17 @@ def _map_extract_plan_fields(data: dict, plan_type: str, status: str) -> dict:
"sourceStatus": status,
"missingFields": missing,
"validFieldCount": valid_count,
"method": (
(data.get("_meta") or {}).get("method")
or (data.get("extraction_meta") or {}).get("method")
or status
),
"provenance": data.get("_provenance") or data.get("provenance") or {},
"lowQualityPages": (
(data.get("_meta") or {}).get("low_quality_pages")
or (data.get("extraction_meta") or {}).get("lowQualityPages")
or []
),
}
return result

View File

@ -712,6 +712,7 @@ class PptRenderer:
return {
"text": full_text,
"fontSize": font_size,
"fontFamily": font.name or "Microsoft YaHei",
"bold": bold,
"color": color,
"align": align_val,

View File

@ -904,19 +904,32 @@ def get_preview(session_id):
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
versions = json.loads(session.versions_json) if session.versions_json else []
requested_revision = request.args.get("revision", type=int)
selected_version = next(
(item for item in versions if item.get("revision") == requested_revision),
None,
) if requested_revision is not None else None
if requested_revision is not None and not selected_version:
return error(ErrorCode.NOT_FOUND, "PPT 版本不存在")
preview_status = session.preview_status or "none"
slides_data = None
slides_json_path = (
selected_version.get("slidesJsonPath")
if selected_version else session.slides_json_path
)
if session.slides_json_path:
if os.path.exists(session.slides_json_path):
if slides_json_path:
if os.path.exists(slides_json_path):
try:
with open(session.slides_json_path, "r", encoding="utf-8") as f:
with open(slides_json_path, "r", encoding="utf-8") as f:
slides_data = json.load(f)
except Exception as e:
logger.warning("读取 slides.json 失败: %s", e)
preview_status = "failed"
else:
logger.warning("slides.json 已被清理: %s", session.slides_json_path)
logger.warning("slides.json 已被清理: %s", slides_json_path)
preview_status = "failed"
quality_report = None
@ -932,8 +945,12 @@ 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 [],
"versions": versions,
"generatedRevision": session.generated_revision or 0,
"viewingRevision": (
requested_revision
if requested_revision is not None else session.generated_revision or 0
),
"generationConfig": (
json.loads(session.draft_options_json)
if session.draft_options_json else {}
@ -941,6 +958,87 @@ def get_preview(session_id):
})
@ppt_bp.route("/preview/<session_id>/versions/<int:revision>/restore", methods=["POST"])
@jwt_required
def restore_preview_version(session_id, revision):
"""把历史版本复制为新的当前版本,保留原历史文件。"""
import shutil
import uuid
from insurance.config import get_storage_root
from insurance.db.compat import db
user_id = str(getattr(request, "user_id", "guest"))
session = _get_session(session_id, user_id)
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
versions = json.loads(session.versions_json) if session.versions_json else []
source = next((item for item in versions if item.get("revision") == revision), None)
if not source:
return error(ErrorCode.NOT_FOUND, "PPT 版本不存在")
ppt_path = source.get("path")
slides_path = source.get("slidesJsonPath")
if not ppt_path or not os.path.exists(ppt_path):
return error(ErrorCode.NOT_FOUND, "历史版本文件已被清理")
output_root = os.path.abspath(os.path.join(get_storage_root(), "outputs", "ppt"))
source_abs = os.path.abspath(ppt_path)
if not source_abs.startswith(output_root):
return error(ErrorCode.PARAM_ERROR, "历史版本路径无效")
new_revision = max(
[int(item.get("revision") or 0) for item in versions] + [session.generated_revision or 0]
) + 1
output_dir = os.path.join(
output_root, str(user_id), f"restore_{uuid.uuid4().hex[:12]}"
)
os.makedirs(output_dir, exist_ok=True)
target_ppt = os.path.join(output_dir, "presentation.pptx")
shutil.copy2(source_abs, target_ppt)
target_slides = None
if slides_path and os.path.exists(slides_path):
slides_abs = os.path.abspath(slides_path)
if not slides_abs.startswith(output_root):
return error(ErrorCode.PARAM_ERROR, "历史预览路径无效")
slides_dir = os.path.join(output_dir, "slides")
os.makedirs(slides_dir, exist_ok=True)
target_slides = os.path.join(slides_dir, "slides.json")
shutil.copy2(slides_abs, target_slides)
restored = {
"revision": new_revision,
"path": target_ppt,
"slidesJsonPath": target_slides,
"deckPath": source.get("deckPath"),
"slideCount": source.get("slideCount") or 0,
"sourceRevision": revision,
"source": "restore",
"createdAt": __import__("datetime").datetime.now().isoformat(),
}
versions.append(restored)
session.versions_json = json.dumps(versions, ensure_ascii=False)
session.ppt_path = target_ppt
session.latest_output_path = target_ppt
session.slides_json_path = target_slides
session.slide_count = restored["slideCount"]
session.preview_status = "ready" if target_slides else "failed"
session.generated_revision = new_revision
session.draft_revision = max(session.draft_revision or 1, new_revision)
db.session.commit()
_record_history(
user_id=user_id,
action_type="restore_version",
session_id=session_id,
content_snapshot={"sourceRevision": revision, "newRevision": new_revision},
file_url=target_ppt,
)
return success({
"sessionId": session_id,
"revision": new_revision,
"sourceRevision": revision,
})
@ppt_bp.route("/preview/<session_id>/slide/<int:index>", methods=["PUT"])
@jwt_required
def update_slide(session_id, index):

View File

@ -1340,6 +1340,12 @@ Content-Disposition: attachment; filename="chat_logs_202606.csv"
| DELETE | `/insurance/admin/ppt/products/{id}` | 软删除产品并保留历史快照 |
| DELETE | `/insurance/admin/ppt/templates/{id}` | 软删除 PPT 模板;内置模板只能停用 |
| DELETE | `/insurance/admin/ppt/copy-templates/{id}` | 软删除文案模板 |
| POST | `/insurance/poster/compliance-check` | 返回文案合规状态、哈希、规则编号和字符区间 |
| GET/PUT | `/insurance/poster/records/{id}/document` | 读取或保存可编辑海报文档 |
| GET | `/insurance/poster/records/{id}/background` | 读取 AI 背景资产 |
| POST | `/insurance/poster/records/{id}/rendered` | 上传浏览器合成的最终 PNG 和文档快照 |
| GET | `/insurance/ppt/preview/{sessionId}?revision={revision}` | 查看当前或指定历史版本预览 |
| POST | `/insurance/ppt/preview/{sessionId}/versions/{revision}/restore` | 将历史版本复制恢复为新版本 |
保司写接口新增 `maskingEnabled`、`logoEnabled`;产品写接口新增 `maskingEnabled`。开启名称脱敏时 `maskedDisplayName` 必填。

View File

@ -1,49 +1,49 @@
<template>
<div ref="canvasRef" class="poster-html-canvas" :class="modeClass" :style="canvasStyle">
<PosterHero
:headline="copyContent?.headline"
:subtitle="copyContent?.body"
:company-name="companyName"
:color-scheme="colorScheme"
:background-image="heroBackground"
:editable="editable"
@edit="onEdit"
/>
<PosterSummaryCards
v-if="hasSummaryData"
:data="parsedFields"
:editable="editable"
@edit="onEdit"
/>
<PosterBenefitChart
v-if="benefitData.length >= 3"
:data="benefitData"
:currency="parsedFields?.currency"
:color-scheme="colorScheme"
/>
<PosterFeatureCards
v-if="features && features.length > 0"
:features="features"
:editable="editable"
@edit="onEdit"
/>
<PosterCallToAction
:text="copyContent?.call_to_action"
:company-name="companyName"
:color-scheme="colorScheme"
:editable="editable"
@edit="onEdit"
/>
<PosterDisclaimer
:company-name="companyName"
:editable="editable"
@edit="onEdit"
/>
<template v-for="section in visibleSections" :key="section.id">
<PosterHero
v-if="section.id === 'hero'"
:headline="copyContent?.headline"
:subtitle="copyContent?.body"
:company-name="companyName"
:color-scheme="colorScheme"
:background-image="heroBackground"
:editable="editable"
@edit="onEdit"
/>
<PosterSummaryCards
v-else-if="section.id === 'summary' && hasSummaryData"
:data="parsedFields"
:editable="editable"
@edit="onEdit"
/>
<PosterBenefitChart
v-else-if="section.id === 'benefits' && benefitData.length >= 3"
:data="benefitData"
:currency="parsedFields?.currency"
:color-scheme="colorScheme"
/>
<PosterFeatureCards
v-else-if="section.id === 'features' && features && features.length > 0"
:features="features"
:editable="editable"
@edit="onEdit"
/>
<PosterCallToAction
v-else-if="section.id === 'cta'"
:text="copyContent?.call_to_action"
:company-name="companyName"
:color-scheme="colorScheme"
:editable="editable"
@edit="onEdit"
/>
<PosterDisclaimer
v-else-if="section.id === 'disclaimer'"
:company-name="companyName"
:editable="editable"
@edit="onEdit"
/>
</template>
</div>
</template>
@ -66,6 +66,7 @@ const props = defineProps<{
features?: Array<{ title: string; summary?: string; icon?: string }>
heroBackground?: string | null
editable?: boolean
sections?: Array<{ id: string; visible: boolean }>
}>()
const emit = defineEmits<{
@ -75,6 +76,18 @@ const emit = defineEmits<{
const canvasRef = ref<HTMLElement | null>(null)
const modeClass = computed(() => props.outputMode === 'long' ? 'poster--long' : 'poster--single')
const visibleSections = computed(() => (
props.sections?.length
? props.sections.filter(section => section.visible)
: [
{ id: 'hero', visible: true },
{ id: 'summary', visible: true },
{ id: 'benefits', visible: true },
{ id: 'features', visible: true },
{ id: 'cta', visible: true },
{ id: 'disclaimer', visible: true },
]
))
const canvasStyle = computed(() => {
const [w, h] = (props.exportSize || '1024x1792').split('x').map(Number)

View File

@ -118,16 +118,17 @@
</template>
</p>
</div>
<button
<div
v-for="(issue, index) in complianceIssues"
:key="`${issue.field}-${issue.start}-${index}`"
type="button"
class="issue-row"
@click="focusIssue(issue)"
>
<strong>{{ fieldName(issue.field) }}{{ issue.text }}</strong>
<span>{{ issue.message }}{{ issue.suggestion }}</span>
</button>
<button type="button" class="issue-main" @click="focusIssue(issue)">
<strong>{{ fieldName(issue.field) }}{{ issue.text }}</strong>
<span>{{ issue.message }}{{ issue.suggestion }}</span>
</button>
<el-button link type="primary" size="small" @click="applySuggestion(issue)">采用建议</el-button>
</div>
</div>
</div>
@ -228,6 +229,14 @@ function focusIssue(issue: any) {
input?.setSelectionRange(issue.start, issue.end)
}
function applySuggestion(issue: any) {
if (!props.copyContent) return
const original = String((props.copyContent as any)[issue.field] || '')
const replacement = issue.replacement || ''
const updated = original.slice(0, issue.start) + replacement + original.slice(issue.end)
emit('update:copy-content', { ...props.copyContent, [issue.field]: updated })
}
async function runComplianceCheck() {
if (!props.copyContent) {
complianceIssues.value = []
@ -399,22 +408,33 @@ onMounted(async () => {
width: 100%;
margin-top: 6px;
padding: 7px 8px;
border: 0;
display: flex;
align-items: center;
gap: 6px;
border-radius: 4px;
background: #fff;
color: #606266;
text-align: left;
}
.issue-main {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.issue-row strong,
.issue-row span {
.issue-main strong,
.issue-main span {
display: block;
font-size: 11px;
line-height: 1.5;
}
.issue-row strong {
.issue-main strong {
color: #b42318;
}

View File

@ -24,6 +24,13 @@
})"
/>
</el-tab-pane>
<el-tab-pane label="布局" name="layout">
<PosterSectionPanel
:sections="draft.sections"
:output-mode="draft.outputMode"
@update="$emit('update:draft', { sections: $event })"
/>
</el-tab-pane>
<el-tab-pane label="发布" name="delivery">
<PosterDeliveryPanel
:draft="draft"
@ -39,6 +46,7 @@ import { ref } from 'vue'
import type { PosterDraft } from '@/composables/usePosterWorkspace'
import PosterCopyPanel from './PosterCopyPanel.vue'
import PosterDeliveryPanel from './PosterDeliveryPanel.vue'
import PosterSectionPanel from './PosterSectionPanel.vue'
defineProps<{ draft: PosterDraft }>()
defineEmits<{ 'update:draft': [patch: Partial<PosterDraft>] }>()

View File

@ -0,0 +1,73 @@
<template>
<div class="section-panel">
<p class="section-hint">控制导出海报包含的内容区块长图模式下可调整顺序</p>
<div v-for="(section, index) in sections" :key="section.id" class="section-row">
<el-switch
:model-value="section.visible"
@change="updateVisible(index, !!$event)"
/>
<span>{{ labels[section.id] || section.id }}</span>
<div v-if="outputMode === 'long'" class="section-actions">
<el-button link size="small" :disabled="index === 0" @click="move(index, -1)">上移</el-button>
<el-button link size="small" :disabled="index === sections.length - 1" @click="move(index, 1)">下移</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
sections: Array<{ id: string; visible: boolean }>
outputMode: 'single' | 'long'
}>()
const emit = defineEmits<{
update: [sections: Array<{ id: string; visible: boolean }>]
}>()
const labels: Record<string, string> = {
hero: '主视觉与标题',
summary: '客户摘要',
benefits: '利益演示',
features: '产品卖点',
cta: '行动号召',
disclaimer: '免责声明',
}
function updateVisible(index: number, visible: boolean) {
const next = props.sections.map(item => ({ ...item }))
next[index].visible = visible
emit('update', next)
}
function move(index: number, offset: number) {
const target = index + offset
if (target < 0 || target >= props.sections.length) return
const next = props.sections.map(item => ({ ...item }))
const [item] = next.splice(index, 1)
next.splice(target, 0, item)
emit('update', next)
}
</script>
<style scoped>
.section-hint {
margin: 0 0 12px;
color: var(--poster-muted);
font-size: 12px;
line-height: 1.6;
}
.section-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 38px;
border-bottom: 1px solid var(--poster-border);
font-size: 13px;
}
.section-actions {
display: flex;
margin-left: auto;
}
</style>

View File

@ -51,8 +51,14 @@
<!-- 解析失败 -->
<div v-else-if="parseStatus === 'failed'" class="failed-row">
<span class="fail-text">{{ parseFailMessage || '解析失败' }}</span>
<el-button text type="primary" size="small" @click="onReset">重新上传</el-button>
<div>
<strong class="fail-text">没有识别到可用字段</strong>
<p class="fail-detail">{{ parseFailMessage || '可能是扫描件质量低、PDF 加密或当前版式暂不支持。' }}</p>
</div>
<div class="fail-actions">
<el-button text type="primary" size="small" @click="onReset">更换文件</el-button>
<el-button text type="primary" size="small" @click="startManualEntry">人工填写</el-button>
</div>
</div>
<!-- 解析成功文件状态行 + 折叠字段 -->
@ -85,6 +91,12 @@
style="margin-bottom: 10px"
/>
<el-alert v-if="editingFields" title="修改数据后需要重新确认" type="warning" :closable="false" show-icon style="margin-bottom: 10px" />
<div v-if="parseDiagnostics" class="parse-diagnostics">
<span>解析方式{{ parseDiagnostics.method || '未知' }}</span>
<span v-if="parseDiagnostics.lowQualityPages?.length">
低质量页{{ parseDiagnostics.lowQualityPages.join('、') }}
</span>
</div>
<el-form label-position="top" size="small">
<el-form-item label="年龄">
<el-input-number v-model="localFields.age" :min="0" :max="100" style="width: 100%" />
@ -195,6 +207,23 @@ const missingFieldsText = computed(() => {
if (!missing.length) return '部分字段未识别,请人工核对并补充'
return `未识别:${missing.map((key: string) => fieldLabels[key] || key).join('、')},请人工补充`
})
const parseDiagnostics = computed(() => props.parsedFields?.meta || null)
function startManualEntry() {
emit('update:parse', {
parseStatus: 'partial',
parsedFields: {
meta: {
status: 'partial',
method: 'manual',
missingFields: ['age', 'currency', 'annual_premium', 'premium_term', 'sum_assured'],
},
},
dataConfirmed: false,
parseFailMessage: '',
})
editingFields.value = true
}
function formatSize(bytes: number) {
if (!bytes) return ''
@ -453,6 +482,18 @@ function onReset() {
color: #f56c6c;
}
.fail-detail {
margin: 4px 0 0;
color: #909399;
font-size: 11px;
line-height: 1.5;
}
.fail-actions {
display: flex;
flex-shrink: 0;
}
/* ── 文件状态行 ────────────────────── */
.file-row {
display: flex;
@ -500,6 +541,18 @@ function onReset() {
margin-top: 8px;
}
.parse-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-bottom: 10px;
padding: 7px 8px;
border-radius: 4px;
background: #f5f7fa;
color: #606266;
font-size: 11px;
}
.field-form :deep(.el-form-item) {
margin-bottom: 10px;
}

View File

@ -17,7 +17,8 @@
:output-mode="draft.outputMode"
:export-size="draft.exportSize"
:features="defaultFeatures"
:hero-background="draft.posterUrl"
:hero-background="draft.backgroundCandidateUrl || draft.posterUrl"
:sections="draft.sections"
:editable="true"
@edit="onCanvasEdit"
/>
@ -39,7 +40,12 @@
</div>
<div v-if="draft.taskStatus === 'done' && draft.posterUrl" class="result-actions">
<el-tag type="success" effect="light">背景已生成文案仍可直接编辑</el-tag>
<el-tag v-if="draft.backgroundCandidateUrl" type="warning" effect="light">新背景待确认</el-tag>
<el-tag v-else type="success" effect="light">背景已生成文案仍可直接编辑</el-tag>
<template v-if="draft.backgroundCandidateUrl">
<el-button type="primary" @click="$emit('accept-background')">使用新背景</el-button>
<el-button @click="$emit('reject-background')">保留原背景</el-button>
</template>
<el-button @click="$emit('retry')">
<el-icon><RefreshRight /></el-icon>
重新生成背景
@ -59,6 +65,8 @@ const props = defineProps<{ draft: PosterDraft }>()
const emit = defineEmits<{
retry: []
edit: [field: string, value: string]
'accept-background': []
'reject-background': []
}>()
const stageRef = ref<HTMLElement | null>(null)

View File

@ -58,6 +58,8 @@ export interface PosterDraft {
referenceImages: Array<{ id: string; url: string; file?: File; name: string }>
// 生成结果
posterUrl: string | null
backgroundCandidateUrl: string | null
sections: Array<{ id: string; visible: boolean }>
// 合规
complianceConfirmed: boolean
complianceStatus: 'unchecked' | 'pass' | 'warn' | 'block'
@ -99,6 +101,15 @@ function createEmptyDraft(): PosterDraft {
taskRecordId: null,
referenceImages: [],
posterUrl: null,
backgroundCandidateUrl: null,
sections: [
{ id: 'hero', visible: true },
{ id: 'summary', visible: true },
{ id: 'benefits', visible: true },
{ id: 'features', visible: true },
{ id: 'cta', visible: true },
{ id: 'disclaimer', visible: true },
],
complianceConfirmed: false,
complianceStatus: 'unchecked',
complianceIssues: [],
@ -156,6 +167,7 @@ export function usePosterWorkspace() {
complianceStatus: d.complianceStatus,
complianceIssues: d.complianceIssues,
complianceRevision: d.complianceRevision,
sections: d.sections,
// 以下字段不持久化taskStatus, taskProgress, taskError, taskRecordId, posterUrl, referenceImages
}
}
@ -248,6 +260,7 @@ export function usePosterWorkspace() {
draft.value.templateId = record.document.templateId || draft.value.templateId
draft.value.outputMode = record.document.outputMode || draft.value.outputMode
draft.value.templateColorScheme = record.document.style || draft.value.templateColorScheme
draft.value.sections = record.document.sections || draft.value.sections
}
if (record.aiRawContent) {
draft.value.aiRawContent = record.aiRawContent

View File

@ -35,6 +35,8 @@
:draft="ws.draft.value"
@retry="onGenerate"
@edit="onCanvasEdit"
@accept-background="acceptBackgroundCandidate"
@reject-background="rejectBackgroundCandidate"
/>
<!-- 右栏文案与发布三栏模式直接显示双栏模式隐藏 -->
@ -308,11 +310,7 @@ function startPolling(id: number) {
if (status === 'done') {
try {
const blob = await posterApi.downloadBackground(id)
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
ws.draft.value.posterUrl = URL.createObjectURL(blob)
await saveCompositeToServer(id)
await applyGeneratedBackground(blob, id)
} catch {
ws.draft.value.taskStatus = 'failed'
ws.draft.value.taskError = '图片下载失败'
@ -334,14 +332,7 @@ function buildPosterDocument() {
copy: d.copyContent,
facts: d.parsedFields,
style: d.templateColorScheme || {},
sections: [
{ id: 'hero', visible: true },
{ id: 'summary', visible: true },
{ id: 'benefits', visible: true },
{ id: 'features', visible: true },
{ id: 'cta', visible: true },
{ id: 'disclaimer', visible: true },
],
sections: d.sections,
complianceRevision: d.complianceRevision,
}
}
@ -353,6 +344,7 @@ watch(
ws.draft.value.templateId,
ws.draft.value.outputMode,
ws.draft.value.templateColorScheme,
ws.draft.value.sections,
],
() => {
const recordId = ws.draft.value.taskRecordId || ws.recordId.value
@ -400,6 +392,34 @@ async function saveCompositeToServer(recordId: number) {
}
}
async function applyGeneratedBackground(blob: Blob, recordId: number) {
const d = ws.draft.value
const nextUrl = URL.createObjectURL(blob)
if (d.posterUrl) {
if (d.backgroundCandidateUrl) URL.revokeObjectURL(d.backgroundCandidateUrl)
d.backgroundCandidateUrl = nextUrl
return
}
d.posterUrl = nextUrl
await saveCompositeToServer(recordId)
}
async function acceptBackgroundCandidate() {
const d = ws.draft.value
if (!d.backgroundCandidateUrl) return
if (d.posterUrl) URL.revokeObjectURL(d.posterUrl)
d.posterUrl = d.backgroundCandidateUrl
d.backgroundCandidateUrl = null
const recordId = d.taskRecordId || ws.recordId.value
if (recordId) await saveCompositeToServer(recordId)
}
function rejectBackgroundCandidate() {
const d = ws.draft.value
if (d.backgroundCandidateUrl) URL.revokeObjectURL(d.backgroundCandidateUrl)
d.backgroundCandidateUrl = null
}
//
async function onDownload() {
const d = ws.draft.value
@ -595,8 +615,7 @@ function startPollingUnified(taskId: string) {
if (pollId) {
try {
const blob = await posterApi.downloadBackground(pollId)
ws.draft.value.posterUrl = URL.createObjectURL(blob)
await saveCompositeToServer(pollId)
await applyGeneratedBackground(blob, pollId)
} catch {
ws.draft.value.taskError = '图片下载失败'
}
@ -623,6 +642,9 @@ watch(
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
if (ws.draft.value.backgroundCandidateUrl) {
URL.revokeObjectURL(ws.draft.value.backgroundCandidateUrl)
}
await restoreWorkspace()
}
},
@ -634,6 +656,9 @@ onBeforeUnmount(() => {
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
if (ws.draft.value.backgroundCandidateUrl) {
URL.revokeObjectURL(ws.draft.value.backgroundCandidateUrl)
}
})
</script>

View File

@ -61,6 +61,9 @@
<div class="toolbar-left">
<el-icon :size="20" color="#67c23a"><SuccessFilled /></el-icon>
<span class="toolbar-title">PPT 已生成</span>
<el-tag v-if="viewingRevision !== currentRevision" type="warning" size="small">
正在查看历史版本 v{{ viewingRevision }}
</el-tag>
<el-tag v-if="generationConfig.templateName" type="info" size="small">
已应用{{ generationConfig.templateName }}
</el-tag>
@ -84,6 +87,9 @@
<aside class="rail-panel">
<div class="rail-header">
<span>幻灯片 ({{ slides.length }})</span>
<el-button link size="small" @click="viewMode = viewMode === 'slide' ? 'grid' : 'slide'">
{{ viewMode === 'grid' ? '单页' : '总览' }}
</el-button>
</div>
<div class="rail-list">
<div
@ -124,7 +130,25 @@
<!-- 中栏当前页大图 -->
<main class="canvas-panel">
<div class="canvas-container" ref="canvasContainerRef">
<div v-if="viewMode === 'grid'" class="slide-grid">
<button
v-for="(slide, idx) in slides"
:key="idx"
type="button"
class="grid-slide"
:class="{ hidden: slide.hidden }"
@click="currentSlide = idx; viewMode = 'slide'"
>
<PptSlideCanvas
:slide="slide"
:slide-width="slideJson?.width || 960"
:slide-height="slideJson?.height || 540"
:scale="0.22"
/>
<span>{{ idx + 1 }}</span>
</button>
</div>
<div v-else class="canvas-container" ref="canvasContainerRef">
<PptSlideCanvas
v-if="currentSlideData"
ref="mainCanvasRef"
@ -132,11 +156,15 @@
:slide-width="slideJson?.width || 960"
:slide-height="slideJson?.height || 540"
:scale="canvasScale"
:editable="true"
:editable="viewingRevision === currentRevision"
:selected-shape-id="selectedShapeId"
@shape-select="onShapeSelected"
@update:shapes="onShapesUpdated"
/>
</div>
<div class="canvas-nav">
<el-button size="small" :disabled="!canUndo" @click="undoEdit">撤销</el-button>
<el-button size="small" :disabled="!canRedo" @click="redoEdit">重做</el-button>
<el-button :icon="ArrowLeft" :disabled="currentSlide <= 0" @click="currentSlide--" circle size="small" />
<span class="page-indicator">
{{ currentSlide + 1 }} / {{ slides.length }}
@ -148,6 +176,65 @@
<!-- 右栏质量检查 + 操作 -->
<aside class="quality-panel">
<div v-if="selectedShape" class="panel-section property-panel">
<h4>对象属性</h4>
<template v-if="selectedShape.type === 'textbox'">
<label class="property-row">
<span>字体</span>
<el-select
:model-value="selectedParagraph?.fontFamily || 'Microsoft YaHei'"
size="small"
@change="applyTextProperty('fontFamily', $event)"
>
<el-option label="微软雅黑" value="Microsoft YaHei" />
<el-option label="苹方" value="PingFang SC" />
<el-option label="Arial" value="Arial" />
</el-select>
</label>
<label class="property-row">
<span>字号</span>
<el-input-number
:model-value="selectedParagraph?.fontSize || 14"
:min="8"
:max="72"
size="small"
@change="applyTextProperty('fontSize', $event)"
/>
</label>
<label class="property-row">
<span>加粗</span>
<el-switch
:model-value="!!selectedParagraph?.bold"
@change="applyTextProperty('bold', $event)"
/>
</label>
<label class="property-row">
<span>颜色</span>
<el-color-picker
:model-value="selectedParagraph?.color || '#333333'"
@change="applyTextProperty('color', $event)"
/>
</label>
<label class="property-row">
<span>对齐</span>
<el-select
:model-value="selectedParagraph?.align || 'left'"
size="small"
@change="applyTextProperty('align', $event)"
>
<el-option label="左对齐" value="left" />
<el-option label="居中" value="center" />
<el-option label="右对齐" value="right" />
<el-option label="两端对齐" value="justify" />
</el-select>
</label>
<p class="property-hint">双击文字可直接编辑内容</p>
</template>
<p v-else-if="selectedShape.type === 'table'" class="property-hint">双击表格单元格可编辑文字</p>
<p v-else-if="selectedShape.type === 'image'" class="property-hint">图片替换将在下一阶段接入源文件上传</p>
<p v-else class="property-hint">当前对象暂不支持属性编辑</p>
</div>
<div class="panel-section">
<h4>系统自动检查</h4>
<ul v-if="qualityReport" class="check-list">
@ -217,10 +304,18 @@
:key="ver.revision"
class="version-item"
:class="{ current: ver.revision === currentRevision }"
@click="loadPreview(ver.revision)"
>
<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>
<el-button
v-else
link
type="primary"
size="small"
@click.stop="restoreVersion(ver.revision)"
>恢复</el-button>
</li>
</ul>
</div>
@ -254,7 +349,7 @@ import {
View, Hide, Refresh,
} from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
import type { SlidesJson, SlideData, SlideShape, QualityReport } from '@/utils/ppt-api'
import type { SlidesJson, SlideData, SlideShape, SlideParagraph, QualityReport } from '@/utils/ppt-api'
import PptSlideCanvas from './PptSlideCanvas.vue'
const props = defineProps<{
@ -278,6 +373,10 @@ const downloadProgress = ref(0)
const regenerating = ref(false)
const canvasContainerRef = ref<HTMLElement | null>(null)
const mainCanvasRef = ref<InstanceType<typeof PptSlideCanvas> | null>(null)
const selectedShapeId = ref<string | null>(null)
const viewMode = ref<'slide' | 'grid'>('slide')
const undoStacks = ref<Record<number, SlideShape[][]>>({})
const redoStacks = ref<Record<number, SlideShape[][]>>({})
const canvasViewport = ref({ width: 0, height: 0 })
let canvasResizeObserver: ResizeObserver | null = null
@ -290,11 +389,18 @@ interface VersionEntry {
}
const versions = ref<VersionEntry[]>([])
const currentRevision = ref(0)
const viewingRevision = ref(0)
const generationConfig = ref<Record<string, any>>({})
//
const slides = computed(() => slideJson.value?.slides || [])
const currentSlideData = computed(() => slides.value[currentSlide.value] || null)
const selectedShape = computed(() =>
currentSlideData.value?.shapes.find(shape => shape.id === selectedShapeId.value) || null
)
const selectedParagraph = computed(() => selectedShape.value?.paragraphs?.[0] || null)
const canUndo = computed(() => (undoStacks.value[currentSlide.value]?.length || 0) > 0)
const canRedo = computed(() => (redoStacks.value[currentSlide.value]?.length || 0) > 0)
const canvasScale = computed(() => {
if (!slideJson.value) return 0.6
@ -361,17 +467,72 @@ async function toggleSlideHidden(index: number) {
}
//
async function onShapesUpdated(shapes: SlideShape[]) {
function cloneShapes(shapes: SlideShape[]): SlideShape[] {
return JSON.parse(JSON.stringify(shapes))
}
function onShapeSelected(shape: SlideShape | null) {
selectedShapeId.value = shape?.id || null
}
async function saveShapes(shapes: SlideShape[], recordHistory = true) {
if (!slideJson.value) return
const index = currentSlide.value
if (recordHistory) {
const stack = undoStacks.value[index] || []
stack.push(cloneShapes(slideJson.value.slides[index].shapes))
if (stack.length > 50) stack.shift()
undoStacks.value[index] = stack
redoStacks.value[index] = []
}
slideJson.value.slides[currentSlide.value].shapes = shapes
try {
await pptApi.updateSlideEdit(props.sessionId, currentSlide.value, shapes)
await pptApi.updateSlideEdit(props.sessionId, index, shapes)
ElMessage.success('已保存编辑')
} catch {
ElMessage.warning('编辑保存失败')
}
}
async function onShapesUpdated(shapes: SlideShape[]) {
await saveShapes(shapes)
}
async function applyTextProperty(key: keyof SlideParagraph, value: any) {
if (!currentSlideData.value || !selectedShape.value || selectedShape.value.type !== 'textbox') return
const shapes = cloneShapes(currentSlideData.value.shapes)
const shape = shapes.find(item => item.id === selectedShape.value?.id)
if (!shape?.paragraphs) return
shape.paragraphs = shape.paragraphs.map(para => ({ ...para, [key]: value }))
await saveShapes(shapes)
}
async function undoEdit() {
if (!slideJson.value) return
const index = currentSlide.value
const stack = undoStacks.value[index] || []
const previous = stack.pop()
if (!previous) return
const redo = redoStacks.value[index] || []
redo.push(cloneShapes(slideJson.value.slides[index].shapes))
redoStacks.value[index] = redo
undoStacks.value[index] = stack
await saveShapes(previous, false)
}
async function redoEdit() {
if (!slideJson.value) return
const index = currentSlide.value
const stack = redoStacks.value[index] || []
const next = stack.pop()
if (!next) return
const undo = undoStacks.value[index] || []
undo.push(cloneShapes(slideJson.value.slides[index].shapes))
undoStacks.value[index] = undo
redoStacks.value[index] = stack
await saveShapes(next, false)
}
//
async function onManualConfirm(key: string, confirmed: boolean) {
try {
@ -399,6 +560,10 @@ function formatTime(iso: string): string {
//
async function handleRegenerate() {
if (viewingRevision.value !== currentRevision.value) {
ElMessage.warning('历史版本为只读,请先恢复为新版本')
return
}
regenerating.value = true
try {
const res: any = await pptApi.regenerateVersion(props.sessionId)
@ -414,6 +579,19 @@ async function handleRegenerate() {
}
}
async function restoreVersion(revision: number) {
regenerating.value = true
try {
await pptApi.restoreVersion(props.sessionId, revision)
ElMessage.success(`已将 v${revision} 恢复为新版本`)
await loadPreview()
} 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()
@ -468,6 +646,14 @@ async function downloadPpt() {
//
function handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null
const isEditing = target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
if (!isEditing && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault()
if (e.shiftKey) redoEdit()
else undoEdit()
return
}
if (e.key === 'ArrowLeft' && currentSlide.value > 0) {
currentSlide.value--
} else if (e.key === 'ArrowRight' && currentSlide.value < slides.value.length - 1) {
@ -475,17 +661,39 @@ function handleKeydown(e: KeyboardEvent) {
}
}
watch(currentSlide, () => {
selectedShapeId.value = null
})
watch(viewMode, async (mode) => {
if (mode !== 'slide') return
await nextTick()
if (!canvasContainerRef.value) return
canvasResizeObserver?.disconnect()
const updateViewport = () => {
if (!canvasContainerRef.value) return
canvasViewport.value = {
width: canvasContainerRef.value.clientWidth,
height: canvasContainerRef.value.clientHeight,
}
}
canvasResizeObserver = new ResizeObserver(updateViewport)
canvasResizeObserver.observe(canvasContainerRef.value)
updateViewport()
})
//
async function loadPreview() {
async function loadPreview(revision?: number) {
loading.value = true
try {
const res: any = await pptApi.getPreview(props.sessionId)
const res: any = await pptApi.getPreview(props.sessionId, revision)
const data = res?.data
previewStatus.value = data?.previewStatus || 'none'
slideJson.value = data?.slides || null
qualityReport.value = data?.qualityReport || null
versions.value = data?.versions || []
currentRevision.value = data?.generatedRevision || 0
viewingRevision.value = data?.viewingRevision ?? currentRevision.value
generationConfig.value = data?.generationConfig || {}
} catch {
previewStatus.value = 'failed'
@ -698,6 +906,9 @@ onUnmounted(() => {
font-weight: 600;
color: #606266;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
justify-content: space-between;
}
.rail-list {
@ -713,6 +924,8 @@ onUnmounted(() => {
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.15s;
content-visibility: auto;
contain-intrinsic-size: 180px 125px;
}
.rail-thumb:hover {
@ -813,6 +1026,41 @@ onUnmounted(() => {
overflow: auto;
}
.slide-grid {
width: 100%;
height: 100%;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-content: start;
gap: 14px;
padding: 4px;
}
.grid-slide {
min-width: 0;
padding: 8px;
border: 2px solid transparent;
border-radius: 8px;
background: #fff;
cursor: pointer;
overflow: hidden;
color: #606266;
}
.grid-slide:hover {
border-color: #3B7A57;
}
.grid-slide.hidden {
opacity: 0.45;
}
.grid-slide :deep(.slide-canvas-wrapper) {
max-width: 100%;
margin: 0 auto 6px;
}
.canvas-nav {
display: flex;
align-items: center;
@ -846,6 +1094,32 @@ onUnmounted(() => {
color: #303133;
}
.property-panel {
background: #f8fbf9;
}
.property-row {
display: grid;
grid-template-columns: 48px 1fr;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
color: #606266;
}
.property-row :deep(.el-select),
.property-row :deep(.el-input-number) {
width: 100%;
}
.property-hint {
margin: 6px 0 0;
color: #909399;
font-size: 11px;
line-height: 1.5;
}
.check-list {
list-style: none;
padding: 0;
@ -943,6 +1217,7 @@ onUnmounted(() => {
border-radius: 4px;
font-size: 12px;
color: #606266;
cursor: pointer;
}
.version-item.current {

View File

@ -8,11 +8,12 @@
role="img"
:aria-label="`幻灯片 ${(slide?.index ?? 0) + 1}`"
@click="handleClick"
@dblclick="handleDoubleClick"
@mousemove="handleMouseMove"
/>
<!-- 行内文字编辑器 -->
<div
v-if="editingShape"
v-if="editingShape || editingCell"
class="text-editor-overlay"
:style="editorStyle"
>
@ -39,11 +40,12 @@ const props = defineProps<{
slideHeight?: number
scale?: number
editable?: boolean
selectedShapeId?: string | null
}>()
const emit = defineEmits<{
'update:shapes': [shapes: SlideShape[]]
'shape-click': [shape: SlideShape]
'shape-select': [shape: SlideShape | null, index: number]
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
@ -53,6 +55,7 @@ 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)
//
const baseWidth = computed(() => props.slideWidth || 960)
@ -69,6 +72,18 @@ const wrapperStyle = computed(() => ({
//
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 {
@ -80,6 +95,13 @@ const editorStyle = computed(() => {
})
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 {
@ -111,6 +133,14 @@ function render() {
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()
}
@ -276,21 +306,40 @@ function handleClick(e: MouseEvent) {
const x = (e.clientX - rect.left) / scale.value
const y = (e.clientY - rect.top) / scale.value
//
const shapes = props.slide.shapes
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i]
if (shape.type !== 'textbox') continue
if (x >= shape.x && x <= shape.x + shape.w &&
y >= shape.y && y <= shape.y + shape.h) {
startEdit(shape, i)
return
}
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) {
editingShape.value = shape
editingCell.value = null
editingShape.value = JSON.parse(JSON.stringify(shape))
editingShapeIndex.value = index
editingText.value = (shape.paragraphs || []).map(p => p.text).join('\n')
nextTick(() => {
@ -299,7 +348,35 @@ function startEdit(shape: SlideShape, index: number) {
})
}
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
@ -328,6 +405,7 @@ function commitEdit() {
function cancelEdit() {
editingShape.value = null
editingCell.value = null
editingShapeIndex.value = -1
}
@ -338,18 +416,17 @@ function handleMouseMove(e: MouseEvent) {
const x = (e.clientX - rect.left) / scale.value
const y = (e.clientY - rect.top) / scale.value
let isOverText = false
let isEditable = false
if (props.slide) {
for (const shape of props.slide.shapes) {
if (shape.type !== 'textbox') continue
if (x >= shape.x && x <= shape.x + shape.w &&
y >= shape.y && y <= shape.y + shape.h) {
isOverText = true
isEditable = ['textbox', 'table', 'image'].includes(shape.type)
break
}
}
}
canvasRef.value.style.cursor = isOverText ? 'text' : 'default'
canvasRef.value.style.cursor = isEditable ? 'pointer' : 'default'
}
//
@ -361,6 +438,8 @@ watch(scale, () => {
nextTick(render)
})
watch(() => props.selectedShapeId, () => nextTick(render))
onMounted(() => {
render()
})

View File

@ -44,6 +44,7 @@ export interface SlideShape {
export interface SlideParagraph {
text: string
fontSize: number
fontFamily?: string
bold: boolean
color: string
align: 'left' | 'center' | 'right' | 'justify'
@ -193,8 +194,10 @@ export const pptApi = {
},
/** 获取幻灯片预览数据(结构化 JSON + 质量报告) */
getPreview(sessionId: string) {
return api.get(`/ppt/preview/${sessionId}`)
getPreview(sessionId: string, revision?: number) {
return api.get(`/ppt/preview/${sessionId}`, {
params: revision === undefined ? undefined : { revision },
})
},
/** 更新指定页的编辑内容 */
@ -217,6 +220,11 @@ export const pptApi = {
return api.post(`/ppt/preview/${sessionId}/regenerate`)
},
/** 将历史版本复制恢复为新的当前版本 */
restoreVersion(sessionId: string, revision: number) {
return api.post(`/ppt/preview/${sessionId}/versions/${revision}/restore`)
},
/** 获取 PDF 某页的预览图 URL */
getPdfPreviewUrl(sessionId: string, extractionIndex: number, pageNumber: number): string {
const base = (api as any).defaults?.baseURL || '/insurance'

View File

@ -185,3 +185,32 @@ def test_poster_compliance_returns_all_character_ranges_and_revision():
]
assert result["issues"][0]["start"] == 0
assert result["issues"][0]["end"] == 2
def test_poster_compliance_warns_and_returns_direct_replacement():
from insurance.poster.compliance import check_copy_compliance
result = check_copy_compliance({
"headline": "行业领先的保障方案",
"body": "具体内容以正式合同为准。",
"call_to_action": "了解详情",
})
assert result["status"] == "warn"
assert result["issues"][0]["severity"] == "warn"
assert result["issues"][0]["replacement"] == "具有特色"
def test_poster_case_mapping_preserves_parse_diagnostics():
from insurance.poster.tasks import _map_extract_plan_fields
mapped = _map_extract_plan_fields({
"insured": {"age": 35},
"policy": {"currency": "USD", "annual_premium": 10000, "premium_payment_period": 5},
"_meta": {"method": "regex+ocr", "low_quality_pages": [3, 8]},
"_provenance": {"insured.age": {"source": "ocr", "confidence": 0.8}},
}, "savings", "partial")
assert mapped["meta"]["method"] == "regex+ocr"
assert mapped["meta"]["lowQualityPages"] == [3, 8]
assert mapped["meta"]["provenance"]["insured.age"]["confidence"] == 0.8