本轮新增:
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:
parent
cb09ed4803
commit
cf77d40569
@ -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 重新生成任务(基于编辑内容生成新版本)。"""
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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` 必填。
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>] }>()
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user