已完成本轮剩余前端功能开发,计划书中的前端核心任务目前没有功能性遗留。

主要完成:
PPT/海报统一“数据可信度”摘要、缺失/冲突/人工修改统计:[ReviewStatusSummary.vue (line 5)](D:/work/code/python/coding/baodanagent/frontend/src/components/generation/ReviewStatusSummary.vue:5)
PPT 证据原文查看、页码定位和 PDF bbox 区域高亮:[PptDataReview.vue (line 464)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:464)、[PdfPagePreview.vue (line 59)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PdfPagePreview.vue:59)
场景匹配原因、场景/模板版本及配置异常阻断:[PptGenerate.vue (line 95)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptGenerate.vue:95)
任务版本、生成哈希、输出一致性、当前/历史结果追溯:[GenerationTracePanel.vue (line 1)](D:/work/code/python/coding/baodanagent/frontend/src/components/generation/GenerationTracePanel.vue:1)、[TasksPage.vue (line 70)](D:/work/code/python/coding/baodanagent/frontend/src/pages/TasksPage.vue:70)
PPT/海报历史页增加“任务与版本追溯”视图:[PptHistoryPage.vue (line 13)](D:/work/code/python/coding/baodanagent/frontend/src/pages/PptHistoryPage.vue:13)、[PosterHistoryPage.vue (line 9)](D:/work/code/python/coding/baodanagent/frontend/src/pages/PosterHistoryPage.vue:9)
治理后台增加 JSON 实时校验、格式化、校验报告、失败重试和移动端适配:[PptGovernanceAdmin.vue (line 206)](D:/work/code/python/coding/baodanagent/frontend/src/pages/admin/PptGovernanceAdmin.vue:206)
恢复此前被注释、点击无反应的产品推荐弹窗:[ChatPage.vue (line 65)](D:/work/code/python/coding/baodanagent/frontend/src/pages/ChatPage.vue:65)
后端 PDF 信息接口补充页面尺寸,用于准确绘制证据框:[routes.py (line 1737)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/routes.py:1737)
验证结果:
全量 ESLint:通过
Vue TypeScript 类型检查:通过
生产构建:通过
PPT 相关定向测试:21 项通过
项目测试:296 项通过、3 项跳过;另有 2 项既有测试环境问题,与本次修改无关
API 健康检查:HTTP 200,数据库、Redis、存储、数据表均正常
前端容器已确认挂载最新 frontend/dist,当前最新资源已通过 9080 端口提供
现在直接访问 http://localhost:9080 即可。容器使用目录挂载,不需要重新构建镜像;如果仍然看到旧界面,请按 Ctrl+F5 强制刷新浏览器缓存。
This commit is contained in:
wsb1224 2026-08-02 17:11:07 +08:00
parent 2422303b36
commit 11f9b108db
32 changed files with 921 additions and 89 deletions

View File

@ -1723,8 +1723,20 @@ def pdf_info(session_id, extraction_index):
import pymupdf as fitz
doc = fitz.open(pdf_path)
page_count = len(doc)
page_sizes = [
{
"pageNumber": index + 1,
"width": round(float(page.rect.width), 3),
"height": round(float(page.rect.height), 3),
}
for index, page in enumerate(doc)
]
doc.close()
return success({"pageCount": page_count, "pdfName": ext.get("pdfName", "")})
return success({
"pageCount": page_count,
"pageSizes": page_sizes,
"pdfName": ext.get("pdfName", ""),
})
except ImportError:
return error(ErrorCode.INTERNAL_ERROR, "PDF 渲染库未安装")
except Exception as e:

Binary file not shown.

View File

@ -0,0 +1,84 @@
# PPT 与海报 Phase 46 补充实施记录
> 日期2026-08-02
> 对应计划:`PPT与海报生成全链路详细修复计划书_20260802.md`
> 结论:代码层已具备版本治理、冻结重放、输出失败关闭、留存清理和灰度观测能力;生产验收尚不能用“代码已完成”替代。
## 1. 本轮已实现
### Phase 4场景、策略与模板可执行化
- 新增 `migrate_038` 及场景、生成策略、PPT 模板三类不可变版本模型;生命周期统一为 `draft -> validated -> published -> retired`
- 发布门禁要求使用已批准 Golden snapshot 试跑;发布后定义不可原地修改,只能复制为新草稿。
- 场景 resolver 使用文件数、险种、保司、产品、优先级和特异度确定唯一结果;无匹配和同优先级多匹配均失败关闭并返回 `matchTrace`
- 运行时严格按 Scenario `pageSpecs`、Template `pageSlots` 和 GenerationPolicy 合并;缺页、重复槽位、容量溢出、资产 SHA 变化和不兼容版本均阻断。
- 管理后台已支持版本创建、JSON 编辑、校验、发布、试跑、复制、停用及失败原因查看。
### Phase 5冻结、对账与任务可靠性
- `migrate_039` 为统一任务补齐 snapshot/scenario/policy/template/asset/renderer、提交版本、对账、视觉检查和结果状态字段。
- PPT 与海报任务按实际使用的快照和配置完整冻结;重放只复用原任务冻结输入,不重新读取当前后台配置。
- PPTX 输出按 XML 可见文本和字段清单核对金额、币种和缺失字段;失败不产生可下载成功成品。
- 海报在生成背景前核对 `renderDocument`;最终浏览器合成时再次提交 DOM 导出清单,服务端校验:
- PNG 实际尺寸;
- DOM 与 PNG 尺寸一致性;
- 必需模块存在且非空;
- 文本裁切/溢出;
- 可见文本 SHA-256
- 冻结文档与计划投影中的金额、币种。
- 最终校验失败时前端不再绕过服务端直接下载。
- 任务支持稳定幂等键、心跳、暂时性错误重试、冻结输入重放和 current/stale 结果标记。
- 旧 PPT 线程解析器已标记 deprecated海报紧凑解析仅在关闭 `DOCUMENT_IR_V1` 时作为短期回滚路径,默认不再承担新链路事实职责。
### Phase 6质量、留存与灰度基础
- `migrate_040` 增加 Golden 审批字段和脱敏清理审计表。
- 管理后台可审批/撤销 Golden snapshot并汇总文档、页面、OCR、表格、跨页表、冲突、快照、覆盖、版本、任务、重试、心跳、延迟、对账及视觉检查指标。
- 新增结构化质量告警:输出对账失败、视觉检查失败、运行任务无心跳、文档阻断和 Golden 数量不足。
- 留存策略覆盖 Document、Snapshot、GenerationTask、Poster、PPT History 和审计日志;支持 dry-run、批次上限、脱敏审计、存储根目录保护和失败记录。
- 新增只读孤儿文件检查,结果仅返回相对路径和 hash不执行自动删除。
- 灰度开关支持按 profile 白名单和稳定百分比分桶:
- `DOCUMENT_IR_V1`
- `PLAN_SNAPSHOT_V1`
- `SCENARIO_ENGINE_V2`
- `OUTPUT_RECONCILIATION_V1`
- `RETENTION_CLEANUP_V1`
## 2. 数据库迁移顺序
部署时须按现有迁移注册顺序执行,新增部分为:
1. `migrate_038`:场景、策略、模板版本;
2. `migrate_039`:任务冻结与输出审计;
3. `migrate_040`Golden 与留存审计。
真实清理默认关闭。必须先执行 dry-run核对候选及孤儿文件再设置 `INSURANCE_RETENTION_CLEANUP_V1=true` 执行实删。
## 3. 建议上线顺序
1. 收集并脱敏不少于 30 份真实 PDF完成双人标注在“生成治理”页面批准为 Golden。
2. 由业务 SME 固化各险种允许的派生公式、缺失值和结论策略,创建并校验 GenerationPolicyVersion。
3. 上传带语义 shape name 的真实 PPTX 模板,逐页声明 `pageType`、slot 和容量,使用 Golden 试跑后发布。
4. 保持 `SCENARIO_ENGINE_V2` 默认关闭,按保司/profile 依次灰度 10% -> 30% -> 100%。
5. 每个阶段观察对账失败、视觉失败、无心跳和人工修改率;出现 P0 告警立即回退对应开关。
6. 连续观察期通过后关闭旧写路径;旧数据只允许只读查看和冻结输入重放。
## 4. 尚需外部输入或生产环境完成的验收
以下事项不能由仓库代码自动代替,因此当前不标记正式里程碑完成:
- 30 份获批脱敏 Golden 的双人标注及强制精确率门槛;
- 业务 SME 对公式、可比较性、缺失值和结论规则的签字;
- 真实生产模板的语义槽位标注、字体安装、图片差异基线和人工视觉验收;
- 生产队列、对象存储、数据库和告警通道上的 dry-run/实删演练;
- 10%/30%/100% 灰度及全量切换后的连续观察期。
因此,当前准确表述是:工程能力已覆盖 Phase 06 的主要代码任务并具备 M5 能力及 M6 基础,但 M2、M4、M6 的正式验收仍受真实样本、业务签字、模板资产和生产观察约束。
## 5. 本轮验证基线
- 后端计划相关专项回归107 passed1 skipped
- 前端 TypeScript 检查:通过;
- 前端生产构建:通过;
- 治理页与海报页静态 UI 审查:无阻断项;
- 仓库全量 `tests/` 中另有 2 个与本轮无关的既有失败,详见交付说明,不能计入本轮回归失败。

View File

@ -5,6 +5,8 @@
> 适用范围:计划书 PDF 解析、人工复核、PlanData、海报生成、PPT 生成、场景与模板、异步任务、质量门禁、测试、监控与数据留存
> 文档性质:实施主计划。既有 PPT、海报和 PDF 专项计划继续作为局部设计参考;若与本计划冲突,以“统一事实快照、字段证据链、失败关闭、版本可追溯”四项原则为准。
> 2026-08-02 实施说明:计划中的代码基础、治理接口和失败关闭门禁已推进至 Phase 6实际里程碑仍须以 30 份获批金标准、真实模板试跑、业务公式签字和灰度观察结果验收。详见 `docs/PPT与海报Phase4至6补充实施记录_20260802.md`
## 1. 结论与实施决策
本次整改不能按“海报修一套、PPT 再修一套”的方式推进。审计报告中的 55 项问题共享四个根因:事实来源分裂、字段语义失真、配置运行时失效、任务与输出不可复现。因此,修复主线确定为:

View File

@ -0,0 +1,15 @@
import pluginVue from 'eslint-plugin-vue'
import { withVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
export default withVueTs(
{ rootDir: import.meta.dirname },
{ ignores: ['dist/**'] },
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'vue/multi-word-component-names': 'off',
},
},
)

View File

@ -24,6 +24,7 @@
"@vitejs/plugin-vue": "^5.2.0",
"@vue/eslint-config-typescript": "^14.0.0",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^10.9.2",
"prettier": "^3.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",

View File

@ -42,6 +42,9 @@ importers:
eslint:
specifier: ^9.0.0
version: 9.39.4
eslint-plugin-vue:
specifier: ^10.9.2
version: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
prettier:
specifier: ^3.4.0
version: 3.8.4

View File

@ -254,9 +254,9 @@ import { ref, computed, onMounted } from 'vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useAuth } from '@/composables/useAuth'
import { useMobile } from '@/composables/useMobile'
import { ChatDotRound, ChatLineRound, Document, Clock, User, Setting, ArrowDown, SwitchButton, Menu, Edit, OfficeBuilding, View, DataBoard, Lock, TrendCharts, Connection, Picture } from '@element-plus/icons-vue'
import { ChatDotRound, ChatLineRound, Document, User, Setting, ArrowDown, SwitchButton, Menu, Edit, OfficeBuilding, View, DataBoard, Lock, TrendCharts, Picture } from '@element-plus/icons-vue'
const { user, logout, restoreFromUrl, checkAuth, fetchPermissions, hasPermission, permissionsLoading } = useAuth()
const { user, logout, restoreFromUrl, fetchPermissions, hasPermission, permissionsLoading } = useAuth()
const showMobileMenu = ref(false)
const { isMobile } = useMobile()

View File

@ -137,7 +137,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { RecommendRequest } from '@/types'
@ -217,7 +217,7 @@ async function onSubmit() {
coverage_amount: formData.coverage_amount * 10000,
}
emit('submit', submitData)
} catch (validationError) {
} catch {
ElMessage.warning('请完善必填信息')
} finally {
submitting.value = false

View File

@ -0,0 +1,140 @@
<template>
<section class="trace-panel" aria-label="生成追溯信息">
<header class="trace-panel__header">
<div>
<strong>生成追溯</strong>
<span>本次任务使用提交时冻结的数据与配置</span>
</div>
<el-tag :type="stateType" effect="plain">{{ stateLabel }}</el-tag>
</header>
<dl class="trace-panel__versions">
<div><dt>数据版本</dt><dd>v{{ task.submitRevision || task.inputRevision || 1 }}</dd></div>
<div><dt>场景版本</dt><dd>{{ versionLabel(task.scenarioVersionId) }}</dd></div>
<div><dt>策略版本</dt><dd>{{ versionLabel(task.policyVersionId) }}</dd></div>
<div><dt>模板版本</dt><dd>{{ versionLabel(task.templateVersionId) }}</dd></div>
</dl>
<div class="trace-panel__checks">
<span :class="checkClass(reconciliationStatus)">输出对账 · {{ checkLabel(reconciliationStatus) }}</span>
<span :class="checkClass(visualStatus)">视觉检查 · {{ checkLabel(visualStatus) }}</span>
<span v-if="task.rendererVersion">渲染器 · {{ task.rendererVersion }}</span>
</div>
<details v-if="hasHashes || matchTrace" class="trace-panel__details">
<summary>查看哈希与命中规则</summary>
<dl v-if="hasHashes" class="trace-panel__hashes">
<div v-for="item in hashes" :key="item.label" v-show="item.value">
<dt>{{ item.label }}</dt><dd :title="item.value">{{ shortHash(item.value) }}</dd>
</div>
</dl>
<div v-if="matchTrace" class="trace-panel__match">
<strong>场景命中</strong>
<p>{{ matchSummary }}</p>
<ul v-if="matchChecks.length">
<li v-for="(check, index) in matchChecks" :key="index">
{{ check.message || check.rule || check.code || JSON.stringify(check) }}
</li>
</ul>
</div>
</details>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ task: Record<string, any> }>()
const stateLabel = computed(() => props.task.resultState === 'stale' ? '历史结果' : '当前结果')
const stateType = computed(() => props.task.resultState === 'stale' ? 'warning' : 'success')
const reconciliationStatus = computed(() => props.task.outputReconciliation?.status || (props.task.status === 'done' ? 'unverified' : 'pending'))
const visualStatus = computed(() => props.task.visualCheck?.status || 'unverified')
const matchTrace = computed(() => props.task.output?.scenarioOverrideTrace || props.task.inputSnapshot?.scenarioOverrideTrace || null)
const matchChecks = computed(() => matchTrace.value?.checks || matchTrace.value?.matchTrace || [])
const matchSummary = computed(() => {
if (!matchTrace.value) return ''
const detected = matchTrace.value.detectedScenario || matchTrace.value.requestedScenario || '未记录'
const final = matchTrace.value.finalScenario || props.task.output?.scenario || '未记录'
return `检测 ${detected},最终使用 ${final}`
})
const hashes = computed(() => [
{ label: '数据快照', value: props.task.snapshotHash },
{ label: '场景定义', value: props.task.scenarioHash },
{ label: '策略定义', value: props.task.policyHash },
{ label: '模板定义', value: props.task.templateHash },
{ label: '模板资产', value: props.task.assetSha256 },
])
const hasHashes = computed(() => hashes.value.some(item => item.value))
function versionLabel(value: unknown) {
return value ? `#${value}` : '旧链路'
}
function shortHash(value: string) {
return value?.length > 18 ? `${value.slice(0, 10)}${value.slice(-6)}` : value
}
function checkLabel(status: string) {
return ({ passed: '通过', pass: '通过', failed: '失败', fail: '失败', pending: '等待中', unverified: '未验证', warning: '需复核' } as Record<string, string>)[status] || status
}
function checkClass(status: string) {
if (['passed', 'pass'].includes(status)) return 'is-pass'
if (['failed', 'fail'].includes(status)) return 'is-fail'
return 'is-pending'
}
</script>
<style scoped>
.trace-panel {
padding: 14px 16px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fbfcfe;
}
.trace-panel__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.trace-panel__header > div { min-width: 0; display: grid; gap: 3px; }
.trace-panel__header strong { color: #172033; font-size: 14px; }
.trace-panel__header span { color: #667085; font-size: 12px; line-height: 1.5; }
.trace-panel__versions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 12px 0;
border-block: 1px solid #e4e7ec;
}
.trace-panel__versions > div { min-width: 0; padding: 10px 8px; }
.trace-panel__versions > div + div { border-inline-start: 1px solid #e4e7ec; }
.trace-panel dt { color: #667085; font-size: 11px; }
.trace-panel dd { margin: 3px 0 0; color: #172033; font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
.trace-panel__checks { display: flex; flex-wrap: wrap; gap: 8px 16px; color: #667085; font-size: 12px; }
.trace-panel__checks .is-pass { color: #18794e; }
.trace-panel__checks .is-fail { color: #b42318; }
.trace-panel__checks .is-pending { color: #9a6700; }
.trace-panel__details { margin-top: 12px; color: #344054; font-size: 12px; }
.trace-panel__details summary { cursor: pointer; font-weight: 650; }
.trace-panel__details summary:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; border-radius: 3px; }
.trace-panel__hashes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 18px; margin: 12px 0 0; }
.trace-panel__hashes > div { min-width: 0; }
.trace-panel__match { margin-top: 12px; }
.trace-panel__match p { margin: 4px 0; color: #667085; }
.trace-panel__match ul { margin: 6px 0 0; padding-inline-start: 18px; }
@media (max-width: 640px) {
.trace-panel__versions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.trace-panel__versions > div:nth-child(3) { border-inline-start: 0; border-top: 1px solid #e4e7ec; }
.trace-panel__versions > div:nth-child(4) { border-top: 1px solid #e4e7ec; }
.trace-panel__hashes { grid-template-columns: 1fr; }
}
</style>

View File

@ -0,0 +1,119 @@
<template>
<section class="review-status" aria-labelledby="review-status-title">
<div class="review-status__heading">
<div>
<strong id="review-status-title">数据可信度</strong>
<span>{{ description }}</span>
</div>
<el-tag :type="statusType" effect="plain">{{ statusLabel }}</el-tag>
</div>
<dl class="review-status__counts" aria-live="polite">
<div v-for="item in visibleItems" :key="item.key" :class="`is-${item.tone || 'neutral'}`">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ReviewStatusItem {
key: string
label: string
value: number | string
tone?: 'neutral' | 'success' | 'warning' | 'danger'
hidden?: boolean
}
const props = withDefaults(defineProps<{
status?: 'reviewing' | 'blocked' | 'confirmed'
description?: string
items: ReviewStatusItem[]
}>(), {
status: 'reviewing',
description: '系统提取结果仍需人工核对,确认后才可用于生成。',
})
const visibleItems = computed(() => props.items.filter(item => !item.hidden))
const statusLabel = computed(() => ({
reviewing: '待复核',
blocked: '存在阻断项',
confirmed: '已确认',
}[props.status]))
const statusType = computed(() => ({
reviewing: 'warning',
blocked: 'danger',
confirmed: 'success',
}[props.status] as 'warning' | 'danger' | 'success'))
</script>
<style scoped>
.review-status {
padding: 14px 16px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #f8fafc;
}
.review-status__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.review-status__heading > div {
min-width: 0;
display: grid;
gap: 3px;
}
.review-status__heading strong {
color: #172033;
font-size: 14px;
}
.review-status__heading span {
color: #667085;
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.review-status__counts {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: 12px 0 0;
}
.review-status__counts > div {
display: flex;
align-items: baseline;
gap: 6px;
}
.review-status__counts dt {
color: #667085;
font-size: 12px;
}
.review-status__counts dd {
margin: 0;
color: #344054;
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.review-status__counts .is-success dd { color: #18794e; }
.review-status__counts .is-warning dd { color: #9a6700; }
.review-status__counts .is-danger dd { color: #b42318; }
@media (max-width: 560px) {
.review-status__heading { align-items: stretch; }
.review-status__counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>

View File

@ -90,7 +90,7 @@ const props = defineProps<{
sections?: Array<{ id: string; visible: boolean }>
}>()
const emit = defineEmits<{
defineEmits<{
edit: [field: string, value: string]
}>()

View File

@ -1,6 +1,6 @@
<template>
<div ref="panelRoot" class="source-panel">
<div class="panel-header" @click="expanded = !expanded">
<button type="button" class="panel-header" :aria-expanded="expanded" @click="expanded = !expanded">
<div class="header-left">
<el-icon :size="16"><Document /></el-icon>
<strong>客户计划书与数据</strong>
@ -10,7 +10,7 @@
<span v-else-if="parseStatus === 'parsed' || parseStatus === 'partial'" class="summary warn">待确认</span>
<el-icon :class="{ 'rotate-180': expanded }"><ArrowDown /></el-icon>
</div>
</div>
</button>
<div v-show="expanded" class="panel-body">
<!-- 未上传上传区域 -->
@ -80,6 +80,13 @@
<el-button text type="primary" size="small" @click="onReset">替换</el-button>
</div>
<ReviewStatusSummary
class="source-review-summary"
:status="dataConfirmed ? 'confirmed' : (missingFieldCount || conflictFieldCount ? 'blocked' : 'reviewing')"
:description="sourceReviewDescription"
:items="sourceReviewItems"
/>
<!-- 已确认收起为摘要 -->
<div v-if="dataConfirmed && !editingFields" class="confirmed-summary">
<el-icon color="#16a34a"><CircleCheckFilled /></el-icon>
@ -200,6 +207,14 @@ import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { Document, ArrowDown, Upload, Loading, CircleCheckFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { posterApi } from '@/utils/poster-api'
import ReviewStatusSummary from '@/components/generation/ReviewStatusSummary.vue'
type ReviewStatusItem = {
key: string
label: string
value: number | string
tone?: 'neutral' | 'success' | 'warning' | 'danger'
}
const props = defineProps<{
productId: string
@ -297,6 +312,27 @@ const hasManualChanges = computed(() => Object.keys(localFields.value).some((key
if (key === 'meta' || key === 'benefit_table') return false
return localFields.value[key] !== props.parsedFields?.[key]
}))
const provenanceEntries = computed<any[]>(() => Object.values(parseDiagnostics.value?.provenance || {}))
const manualSourceCount = computed(() => provenanceEntries.value.filter(item => item?.source === 'manual_override').length)
const derivedSourceCount = computed(() => provenanceEntries.value.filter(item => item?.source === 'system_derived').length)
const missingFieldCount = computed(() => (parseDiagnostics.value?.missingFields || []).length)
const conflictFieldCount = computed(() => (parseDiagnostics.value?.conflicts || []).length)
const automaticFieldCount = computed(() => Math.max(
0,
confirmedFieldCount.value - manualSourceCount.value - derivedSourceCount.value,
))
const sourceReviewItems = computed<ReviewStatusItem[]>(() => [
{ key: 'automatic', label: '自动提取', value: automaticFieldCount.value, tone: 'success' },
{ key: 'missing', label: '缺失', value: missingFieldCount.value, tone: missingFieldCount.value ? 'danger' : 'neutral' },
{ key: 'conflict', label: '冲突', value: conflictFieldCount.value, tone: conflictFieldCount.value ? 'danger' : 'neutral' },
{ key: 'derived', label: '系统推导', value: derivedSourceCount.value, tone: derivedSourceCount.value ? 'warning' : 'neutral' },
{ key: 'manual', label: '人工覆盖', value: manualSourceCount.value + (hasManualChanges.value ? 1 : 0), tone: manualSourceCount.value || hasManualChanges.value ? 'warning' : 'neutral' },
])
const sourceReviewDescription = computed(() => {
if (props.dataConfirmed) return '这份数据已经人工确认;再次修改后需要重新确认。'
if (missingFieldCount.value || conflictFieldCount.value) return '请补齐缺失项并处理冲突,服务端会在确认时再次校验。'
return '请核对金额、币种和客户信息,确认后才能进入生成。'
})
function fieldSourceText(field: string, derivedText: string) {
const source = parseDiagnostics.value?.provenance?.[field]?.source
@ -521,14 +557,23 @@ onBeforeUnmount(stopPolling)
<style scoped>
.panel-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 8px 0;
user-select: none;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: start;
}
.panel-header:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; border-radius: 4px; }
.source-review-summary { margin: 10px 0; }
.header-left {
display: flex;
align-items: center;

View File

@ -6,7 +6,7 @@
* 2.
* 3.
*/
import { ref, watch, onUnmounted } from 'vue'
import { ref, onUnmounted } from 'vue'
import api from '@/utils/api'
export interface AutoSaveOptions {

View File

@ -7,7 +7,7 @@
* 3. API
* 4.
*/
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import api from '@/utils/api'
import { useAuth } from '@/composables/useAuth'

View File

@ -11,7 +11,7 @@
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import api from '@/utils/api'
import { mapPptTaskToStep, isTaskActive, isTaskTerminal } from '@/utils/taskPhaseMapper'
import { mapPptTaskToStep } from '@/utils/taskPhaseMapper'
export interface WorkspaceState {
sessionId: string

View File

@ -60,19 +60,23 @@
</div>
</div>
<!-- 产品推荐弹窗 -->
<!-- <el-dialog v-model="showRecommend" title="🎯 产品推荐" width="90%" :close-on-click-modal="false"-->
<!-- class="recommend-dialog">-->
<!-- <RecommendForm @submit="onRecommendSubmit" :loading="recommendLoading" />-->
<el-dialog
v-model="showRecommend"
title="产品推荐"
width="min(90vw, 760px)"
:close-on-click-modal="false"
class="recommend-dialog"
>
<RecommendForm :loading="recommendLoading" @submit="onRecommendSubmit" />
<!-- <div v-if="recommendGenerating" class="generating-tip">-->
<!-- <el-icon class="is-loading" :size="24"><Loading /></el-icon>-->
<!-- <p>正在生成方案请稍候...</p>-->
<!-- </div>-->
<div v-if="recommendGenerating" class="generating-tip" role="status" aria-live="polite">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<p>正在生成推荐方案请稍候</p>
</div>
<!-- <el-divider v-if="recommendResult" />-->
<!-- <RecommendResult v-if="recommendResult" :result="recommendResult" />-->
<!-- </el-dialog>-->
<el-divider v-if="recommendResult" />
<RecommendResult v-if="recommendResult" :result="recommendResult" />
</el-dialog>
</div>
</template>
@ -182,7 +186,7 @@ function newChat() {
chatEmbedRef.value?.resetChat()
}
function onNewAnswer(_answer: string) {}
function onNewAnswer() {}
function onSessionCreated(sessionId: string) {
//

View File

@ -4,6 +4,12 @@
<h2>海报历史</h2>
</div>
<div class="history-view-switch" role="tablist" aria-label="海报历史视图">
<button type="button" role="tab" :aria-selected="activeView === 'artifacts'" :class="{ active: activeView === 'artifacts' }" @click="activeView = 'artifacts'">海报成品</button>
<button type="button" role="tab" :aria-selected="activeView === 'tasks'" :class="{ active: activeView === 'tasks' }" @click="activeView = 'tasks'">任务与版本追溯</button>
</div>
<template v-if="activeView === 'artifacts'">
<div class="poster-history-cards" v-loading="loading">
<article v-for="row in list" :key="row.id" class="poster-history-card">
<div class="poster-history-card__top">
@ -67,6 +73,9 @@
<el-pagination v-if="total > pageSize" layout="total, prev, pager, next"
:total="total" :page-size="pageSize" v-model:current-page="page"
@current-change="loadData" style="margin-top: 16px; justify-content: flex-end" />
</template>
<TasksPage v-else artifact-type="poster" embedded />
<el-dialog v-model="detailVisible" title="海报详情" width="min(600px, 94vw)">
<div v-if="detailRecord">
@ -92,6 +101,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { posterApi } from '@/utils/poster-api'
import { useRouter } from 'vue-router'
import TasksPage from '@/pages/TasksPage.vue'
const router = useRouter()
@ -100,6 +110,7 @@ const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const activeView = ref<'artifacts' | 'tasks'>('artifacts')
const detailVisible = ref(false)
const detailRecord = ref<any>(null)
@ -147,7 +158,7 @@ async function onDownload(row: any) {
a.download = `poster_${row.id}.png`
a.click()
URL.revokeObjectURL(url)
} catch (e: any) {
} catch {
ElMessage.error('下载失败')
}
}
@ -167,9 +178,14 @@ onBeforeUnmount(() => {
.page-container { height: 100%; padding: 20px; overflow: auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.poster-history-cards { display: none; }
.history-view-switch { display: inline-flex; gap: 4px; margin-bottom: 16px; padding: 4px; border: 1px solid #d7e0ea; border-radius: 8px; background: #f8fafc; }
.history-view-switch button { min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; background: transparent; color: #667085; cursor: pointer; }
.history-view-switch button.active { background: #fff; color: #172033; box-shadow: 0 1px 3px rgba(16, 24, 40, .12); }
.history-view-switch button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
@media (max-width: 767px) {
.page-container { padding: 12px; }
.history-view-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; }
.poster-history-table { display: none; }
.poster-history-cards { display: grid; gap: 12px; }
.poster-history-card { padding: 14px; border: 1px solid #e4e7ed; border-radius: 10px; background: #fff; }

View File

@ -8,6 +8,12 @@
<el-button @click="router.push('/ppt')">返回工作台</el-button>
</div>
<div class="history-view-switch" role="tablist" aria-label="历史记录视图">
<button type="button" role="tab" :aria-selected="activeView === 'artifacts'" :class="{ active: activeView === 'artifacts' }" @click="activeView = 'artifacts'">成品记录</button>
<button type="button" role="tab" :aria-selected="activeView === 'tasks'" :class="{ active: activeView === 'tasks' }" @click="activeView = 'tasks'">任务与版本追溯</button>
</div>
<template v-if="activeView === 'artifacts'">
<div class="filter-bar">
<el-select v-model="filter.company_id" placeholder="保司" clearable style="width: 140px" @change="loadData">
<el-option v-for="c in companies" :key="c.id" :label="c.displayName" :value="c.id" />
@ -73,6 +79,9 @@
@current-change="loadData"
style="margin-top: 16px; justify-content: flex-end"
/>
</template>
<TasksPage v-else artifact-type="ppt" embedded />
<el-dialog v-model="detailVisible" title="历史详情" width="min(600px, 94vw)">
<pre class="snapshot-view">{{ detailSnapshot }}</pre>
@ -86,6 +95,7 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { pptApi } from '@/utils/ppt-api'
import { pptAdminApi } from '@/utils/ppt-admin-api'
import TasksPage from '@/pages/TasksPage.vue'
const router = useRouter()
@ -114,6 +124,7 @@ const filter = ref({ company_id: '', action_type: '' })
const detailVisible = ref(false)
const detailSnapshot = ref('')
const downloadingId = ref<number | null>(null)
const activeView = ref<'artifacts' | 'tasks'>('artifacts')
function formatTime(iso: string): string {
if (!iso) return ''
@ -207,6 +218,29 @@ async function onReDownload(row: any) {
margin-bottom: 16px;
}
.history-view-switch {
display: inline-flex;
gap: 4px;
margin-bottom: 16px;
padding: 4px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #f8fafc;
}
.history-view-switch button {
min-height: 36px;
padding: 0 14px;
border: 0;
border-radius: 6px;
background: transparent;
color: #667085;
cursor: pointer;
}
.history-view-switch button.active { background: #fff; color: #172033; box-shadow: 0 1px 3px rgba(16, 24, 40, .12); }
.history-view-switch button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
/* 卡片网格 */
.history-grid {
display: grid;
@ -322,6 +356,8 @@ async function onReDownload(row: any) {
grid-template-columns: 1fr;
}
.history-view-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; }
.filter-bar :deep(.el-select) {
width: 100% !important;
}

View File

@ -167,7 +167,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, provide, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Bell, Clock, RefreshLeft, ArrowLeft, ArrowDown, Close } from '@element-plus/icons-vue'
import { Bell, Clock, RefreshLeft, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import { usePptWorkspace } from '@/composables/useWorkspace'
import { useAutoSave } from '@/composables/useAutoSave'
import DraftIndicator from '@/components/DraftIndicator.vue'

View File

@ -156,7 +156,7 @@ async function onRegister() {
//
router.push('/chat')
} catch (error: any) {
} catch {
// api
} finally {
loading.value = false

View File

@ -15,6 +15,7 @@
<el-option label="执行中" value="running" />
<el-option label="已完成" value="done" />
<el-option label="失败" value="failed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</div>
</section>
@ -30,8 +31,19 @@
<el-option label="执行中" value="running" />
<el-option label="已完成" value="done" />
<el-option label="失败" value="failed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</div>
<el-alert
v-if="fetchError"
class="tasks-error"
type="error"
:closable="false"
show-icon
:title="fetchError"
>
<template #default><el-button link type="primary" @click="fetchTasks">重新加载</el-button></template>
</el-alert>
<div v-if="loading || tasks.length > 0" class="mobile-task-list" v-loading="loading">
<article v-for="row in tasks" :key="row.id" class="mobile-task-card">
<header>
@ -55,6 +67,11 @@
<span>{{ row.artifactType === 'ppt' ? 'PPT' : '海报' }}</span>
<span>{{ formatTime(row.createdAt) }}</span>
</div>
<GenerationTracePanel
v-if="['generate', 'regenerate'].includes(row.operation)"
class="mobile-task-trace"
:task="row"
/>
<div class="mobile-task-actions">
<el-button type="primary" @click="goToWorkspace(row)">查看</el-button>
<el-button v-if="row.status === 'done' && row.output?.downloadUrl" :loading="downloadingTaskId === row.id" @click="download(row)">下载</el-button>
@ -65,6 +82,14 @@
</div>
<el-table class="desktop-task-table" v-if="loading || tasks.length > 0" :data="tasks" v-loading="loading" stripe style="width: 100%">
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div class="task-trace-row">
<GenerationTracePanel v-if="['generate', 'regenerate'].includes(row.operation)" :task="row" />
<el-empty v-else description="解析任务没有生成版本信息" :image-size="44" />
</div>
</template>
</el-table-column>
<el-table-column label="任务" min-width="200">
<template #default="{ row }">
<div class="task-name-cell">
@ -88,6 +113,14 @@
<el-tag size="small" :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="结果版本" width="108">
<template #default="{ row }">
<el-tag v-if="row.operation === 'generate' || row.operation === 'regenerate'" size="small" :type="row.resultState === 'stale' ? 'warning' : 'success'" effect="plain">
{{ row.resultState === 'stale' ? '历史结果' : '当前结果' }}
</el-tag>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="进度" width="160">
<template #default="{ row }">
<el-progress
@ -150,6 +183,7 @@ import { useRouter } from 'vue-router'
import { Loading, CircleCheck, CircleClose, Clock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import api from '@/utils/api'
import GenerationTracePanel from '@/components/generation/GenerationTracePanel.vue'
const router = useRouter()
const props = withDefaults(defineProps<{
@ -167,6 +201,7 @@ const loading = ref(false)
const initialized = ref(false)
const downloadingTaskId = ref<string | null>(null)
const replayingTaskId = ref<string | null>(null)
const fetchError = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
@ -176,6 +211,7 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
async function fetchTasks() {
loading.value = !initialized.value
fetchError.value = ''
try {
const params: any = { page: page.value, page_size: pageSize }
const selectedType = props.artifactType || filterType.value
@ -185,7 +221,9 @@ async function fetchTasks() {
const data = res?.data ?? {}
tasks.value = data.items || []
total.value = data.total || 0
} catch { /* ignore */ }
} catch (error: any) {
fetchError.value = error?.response?.data?.message || '任务记录加载失败,请检查网络后重试。'
}
finally {
loading.value = false
initialized.value = true
@ -383,6 +421,10 @@ onUnmounted(() => {
padding: 16px 0 0;
}
.tasks-error { margin-bottom: 14px; }
.task-trace-row { padding: 8px 16px 16px 44px; }
.mobile-task-trace { margin-top: 12px; }
.mobile-task-list {
display: none;
}

View File

@ -16,6 +16,16 @@
title="发布门禁需要已确认的金标准快照"
description="没有样本快照时只能保存草稿;校验、发布和严格场景链路都会失败关闭。"
/>
<el-alert
v-if="governanceError"
class="governance-error"
type="error"
:closable="false"
show-icon
:title="governanceError"
>
<template #default><el-button link type="primary" :loading="loading" @click="refreshAll">重新加载</el-button></template>
</el-alert>
<div v-if="qualityAlerts.length" class="quality-alerts" aria-label="质量告警">
<el-alert
v-for="item in qualityAlerts"
@ -173,7 +183,7 @@ import { computed, defineComponent, h, onMounted, ref } from 'vue'
import { ElButton, ElInput, ElMessage, ElMessageBox, ElOption, ElSelect, ElTable, ElTableColumn, ElTag } from 'element-plus'
import { pptAdminApi } from '@/utils/ppt-admin-api'
type FieldDefinition = { key: string; label: string; rows?: number; value: string }
type FieldDefinition = { key: string; label: string; hint?: string; rows?: number; value: string }
const VersionEditor = defineComponent({
props: {
@ -191,17 +201,23 @@ const VersionEditor = defineComponent({
onChange: () => emit('selectionChange'), placeholder: '请选择稳定配置', class: 'full-width',
}, () => props.options.map(item => h(ElOption, { key: item.value, label: item.label, value: item.value }))),
...props.fields.map(field => h('label', { class: 'json-field' }, [
h('span', { class: 'json-field__heading' }, [
h('span', field.label),
h(ElButton, { link: true, type: 'primary', onClick: () => formatJsonField(field) }, () => '格式化'),
]),
field.hint ? h('small', { class: 'json-field__hint' }, field.hint) : null,
h(ElInput, {
modelValue: field.value, 'onUpdate:modelValue': (value: string) => { field.value = value },
type: 'textarea', rows: field.rows || 4, spellcheck: false,
ariaInvalid: Boolean(jsonFieldError(field)),
}),
jsonFieldError(field) ? h('small', { class: 'json-field__error', role: 'alert' }, jsonFieldError(field)) : null,
])),
...(props.showSamples ? [h('label', { class: 'json-field' }, [
h('span', '金标准快照 ID逗号分隔'),
h(ElInput, { modelValue: props.sampleIds, 'onUpdate:modelValue': (value: string) => emit('update:sampleIds', value), placeholder: '例如 101, 102' }),
])] : []),
h(ElButton, { type: 'primary', loading: props.saving, disabled: !props.selected, onClick: () => emit('create') }, () => '保存新草稿'),
h(ElButton, { type: 'primary', loading: props.saving, disabled: !props.selected || props.fields.some(jsonFieldError), onClick: () => emit('create') }, () => '保存新草稿'),
])
},
})
@ -213,6 +229,12 @@ const VersionTable = defineComponent({
return () => h('section', { class: 'versions-panel' }, [
h('div', { class: 'panel-heading' }, [h('h2', '版本记录'), h('p', '发布后定义不可修改;更新时创建下一版草稿。')]),
h(ElTable, { data: props.items, size: 'small', emptyText: '尚无版本' }, () => [
h(ElTableColumn, { type: 'expand', width: 42 }, { default: ({ row }: any) => h('div', { class: 'validation-detail' }, [
h('strong', '校验报告'),
row.validationReport?.issues?.length
? h('ul', row.validationReport.issues.map((issue: any) => h('li', { class: issue.severity === 'error' ? 'is-error' : 'is-warning' }, issue.message || issue.code)))
: h('p', row.validationReport?.valid ? '所有发布门禁均已通过。' : '尚未运行校验。'),
]) }),
h(ElTableColumn, { prop: 'version', label: '版本', width: 72 }),
h(ElTableColumn, { label: '状态', width: 110 }, { default: ({ row }: any) => h(ElTag, { type: lifecycleType(row.lifecycle), effect: 'plain' }, () => lifecycleLabel(row.lifecycle)) }),
h(ElTableColumn, { prop: 'definitionHash', label: '定义 hash', minWidth: 150, showOverflowTooltip: true }),
@ -237,6 +259,7 @@ const goldenSaving = ref(false)
const busyVersionId = ref(0)
const activeTab = ref('scenario')
const quality = ref<any>({})
const governanceError = ref('')
const qualityAlerts = ref<any[]>([])
const featureFlags = ref<Record<string, boolean>>({})
const retention = ref<any>({ cleanupEnabled: false, categories: {} })
@ -258,22 +281,22 @@ const policyVersions = ref<any[]>([])
const templateVersions = ref<any[]>([])
const scenarioFields = ref<FieldDefinition[]>([
{ key: 'selector', label: 'Selector JSON', rows: 4, value: '{\n "priority": 10,\n "fileCount": 1,\n "planTypes": ["savings"]\n}' },
{ key: 'pageSpecs', label: 'Page specs JSON', rows: 6, value: '[\n {"pageType": "cover", "title": "方案封面"}\n]' },
{ key: 'metricSpecs', label: 'Metric specs JSON', value: '[]' },
{ key: 'comparisonYears', label: 'Comparison years JSON', value: '[10, 20, 30]' },
{ key: 'compatibilityRules', label: 'Compatibility rules JSON', value: '{}' },
{ key: 'selector', label: '匹配规则', hint: '设置优先级、文件数量和允许的险种组合。', rows: 4, value: '{\n "priority": 10,\n "fileCount": 1,\n "planTypes": ["savings"]\n}' },
{ key: 'pageSpecs', label: '业务页面清单', hint: '按照最终 PPT 顺序声明 pageType页面无法匹配时会阻断发布。', rows: 6, value: '[\n {"pageType": "cover", "title": "方案封面"}\n]' },
{ key: 'metricSpecs', label: '指标规则', hint: '声明指标字段、适用页面和是否必填。', value: '[]' },
{ key: 'comparisonYears', label: '比较年份', hint: '例如 10、20、30 年;只显示业务确认过的年份。', value: '[10, 20, 30]' },
{ key: 'compatibilityRules', label: '兼容性规则', hint: '描述允许或禁止的险种、产品和保司组合。', value: '{}' },
])
const policyFields = ref<FieldDefinition[]>([
{ key: 'calculationPolicy', label: 'Calculation policy JSON', rows: 5, value: '{\n "derivedMetrics": []\n}' },
{ key: 'missingValuePolicy', label: 'Missing value policy JSON', value: '{"mode": "block"}' },
{ key: 'conclusionPolicy', label: 'Conclusion policy JSON', value: '{"mode": "rules_only"}' },
{ key: 'calculationPolicy', label: '计算策略', hint: '只配置经过业务签字的派生指标;空数组表示不进行推导。', rows: 5, value: '{\n "derivedMetrics": []\n}' },
{ key: 'missingValuePolicy', label: '缺失值策略', hint: 'block 表示关键值缺失时禁止生成。', value: '{"mode": "block"}' },
{ key: 'conclusionPolicy', label: '结论策略', hint: 'rules_only 表示只使用确定性规则,不让模型自由编写结论。', value: '{"mode": "rules_only"}' },
])
const templateFields = ref<FieldDefinition[]>([
{ key: 'pageSlots', label: 'Page slots JSON', rows: 8, value: '[\n {\n "slotId": "cover-1",\n "pageType": "cover",\n "sourceSlideIndex": 1,\n "shapeSlots": [{"shapeName": "BusinessTitle", "role": "title"}]\n }\n]' },
{ key: 'capacityRules', label: 'Capacity rules JSON', value: '{"maxTitleChars": 40}' },
{ key: 'supportedScenarioVersionIds', label: '支持的已发布场景版本 ID JSON', value: '[]' },
{ key: 'theme', label: 'Theme JSON', value: '{}' },
{ key: 'pageSlots', label: '页面与槽位映射', hint: '源页码从 1 开始shapeName 必须与模板内业务形状名称一致。', rows: 8, value: '[\n {\n "slotId": "cover-1",\n "pageType": "cover",\n "sourceSlideIndex": 1,\n "shapeSlots": [{"shapeName": "BusinessTitle", "role": "title"}]\n }\n]' },
{ key: 'capacityRules', label: '内容容量规则', hint: '标题、正文、表格行数超过容量时必须有明确阻断或降级策略。', value: '{"maxTitleChars": 40}' },
{ key: 'supportedScenarioVersionIds', label: '支持的场景版本', hint: '填写已发布场景版本 ID 数组,例如 [12, 15]。', value: '[]' },
{ key: 'theme', label: '主题参数', hint: '仅控制视觉参数,不用于定义业务页面。', value: '{}' },
])
const scenarioOptions = computed(() => scenarios.value.map(item => ({ label: `${item.name} · ${item.code}`, value: item.code })))
@ -283,6 +306,7 @@ onMounted(refreshAll)
async function refreshAll() {
loading.value = true
governanceError.value = ''
try {
const [qualityRes, retentionRes, scenarioRes, templateRes, candidateRes, goldenRes]: any[] = await Promise.all([
pptAdminApi.getQualitySummary(), pptAdminApi.getRetentionPolicy(),
@ -302,7 +326,7 @@ async function refreshAll() {
if (!selectedTemplate.value) selectedTemplate.value = templates.value[0]?.id || ''
await Promise.all([loadScenarioVersions(), loadPolicyVersions(), loadTemplateVersions()])
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '治理状态加载失败')
governanceError.value = error?.response?.data?.message || '治理状态加载失败,请检查服务后重试。'
} finally {
loading.value = false
}
@ -329,6 +353,18 @@ function parseFields(fields: FieldDefinition[]) {
catch { throw new Error(`${field.label} 不是有效 JSON`) }
}))
}
function jsonFieldError(field: FieldDefinition) {
try { JSON.parse(field.value); return '' }
catch (error: any) { return `JSON 格式错误:${error?.message || '请检查括号和引号'}` }
}
function formatJsonField(field: FieldDefinition) {
const message = jsonFieldError(field)
if (message) {
ElMessage.warning(message)
return
}
field.value = JSON.stringify(JSON.parse(field.value), null, 2)
}
function sampleIds(value: string): number[] {
return [...new Set(value.split(',').map(item => Number(item.trim())).filter(item => Number.isInteger(item) && item > 0))]
}
@ -348,7 +384,7 @@ async function versionAction(row: any, action: () => Promise<any>, reload: () =>
catch (error: any) { ElMessage.error(error?.response?.data?.message || '操作失败') }
finally { busyVersionId.value = 0 }
}
const validateScenario = (row: any) => versionAction(row, () => pptAdminApi.validateScenarioVersion(row.id, sampleIds(scenarioSamples.value)), loadScenarioVersions, '场景版本校验通过')
const validateScenario = (row: any) => validateWithSamples(row, scenarioSamples.value, ids => pptAdminApi.validateScenarioVersion(row.id, ids), loadScenarioVersions, '场景版本校验通过')
const publishScenario = (row: any) => versionAction(row, () => pptAdminApi.publishScenarioVersion(row.id), loadScenarioVersions, '场景版本已发布')
const cloneScenario = (row: any) => versionAction(row, () => pptAdminApi.cloneScenarioVersion(row.id), loadScenarioVersions, '已复制为新草稿')
const retireScenario = (row: any) => versionAction(row, () => pptAdminApi.retireScenarioVersion(row.id), loadScenarioVersions, '场景版本已停用')
@ -356,10 +392,18 @@ const validatePolicy = (row: any) => versionAction(row, () => pptAdminApi.valida
const publishPolicy = (row: any) => versionAction(row, () => pptAdminApi.publishPolicyVersion(row.id), loadPolicyVersions, '策略版本已发布')
const clonePolicy = (row: any) => versionAction(row, () => pptAdminApi.clonePolicyVersion(row.id), loadPolicyVersions, '已复制为新草稿')
const retirePolicy = (row: any) => versionAction(row, () => pptAdminApi.retirePolicyVersion(row.id), loadPolicyVersions, '策略版本已停用')
const validateTemplate = (row: any) => versionAction(row, () => pptAdminApi.validateTemplateVersion(row.id, sampleIds(templateSamples.value)), loadTemplateVersions, '模板版本校验通过')
const validateTemplate = (row: any) => validateWithSamples(row, templateSamples.value, ids => pptAdminApi.validateTemplateVersion(row.id, ids), loadTemplateVersions, '模板版本校验通过')
const publishTemplate = (row: any) => versionAction(row, () => pptAdminApi.publishTemplateVersion(row.id), loadTemplateVersions, '模板版本已发布')
const cloneTemplate = (row: any) => versionAction(row, () => pptAdminApi.cloneTemplateVersion(row.id), loadTemplateVersions, '已复制为新草稿')
const retireTemplate = (row: any) => versionAction(row, () => pptAdminApi.retireTemplateVersion(row.id), loadTemplateVersions, '模板版本已停用')
async function validateWithSamples(row: any, rawSampleIds: string, action: (ids: number[]) => Promise<any>, reload: () => Promise<any>, message: string) {
const ids = sampleIds(rawSampleIds)
if (!ids.length) {
ElMessage.warning('请先填写至少一个已批准的金标准快照 ID')
return
}
await versionAction(row, () => action(ids), reload, message)
}
async function tryScenario(row: any) {
try {
const prompt = await ElMessageBox.prompt('输入试跑上下文 JSON', `试跑场景 v${row.version}`, {
@ -443,6 +487,7 @@ function firstIssue(row: any) { return row.validationReport?.issues?.[0]?.messag
.page-header h1 { margin: 2px 0 8px; font-size: 28px; }
.page-header p { margin: 0; color: #667085; }
.quality-alerts { display: grid; gap: 8px; margin: 14px 0; }
.governance-error { margin-top: 14px; }
.eyebrow { color: #2563eb !important; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 20px 0; border: 1px solid #e4e7ec; border-radius: 12px; overflow: hidden; }
.summary-item { padding: 18px 20px; background: #fff; border-right: 1px solid #e4e7ec; }
@ -463,8 +508,15 @@ function firstIssue(row: any) { return row.validationReport?.issues?.[0]?.messag
.panel-heading { margin-bottom: 16px; }
.full-width { width: 100%; margin-bottom: 14px; }
.json-field { display: block; margin-bottom: 14px; }
.json-field > span { display: block; margin-bottom: 6px; color: #344054; font-size: 13px; font-weight: 600; }
.json-field__heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px; color: #344054; font-size: 13px; font-weight: 600; }
.json-field__hint { display: block; margin: -2px 0 7px; color: #667085; font-size: 12px; line-height: 1.45; }
.json-field :deep(textarea) { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; line-height: 1.5; }
.json-field__error { display: block; margin-top: 5px; color: #b42318; line-height: 1.45; overflow-wrap: anywhere; }
.validation-detail { padding: 10px 18px 14px 42px; color: #344054; font-size: 13px; }
.validation-detail p { margin: 6px 0 0; color: #667085; }
.validation-detail ul { margin: 8px 0 0; padding-inline-start: 20px; }
.validation-detail .is-error { color: #b42318; }
.validation-detail .is-warning { color: #9a6700; }
@media (max-width: 900px) {
.governance-page { padding: 18px; }
.page-header { flex-direction: column; }
@ -473,5 +525,17 @@ function firstIssue(row: any) { return row.validationReport?.issues?.[0]?.messag
.summary-item:nth-child(-n+2) { border-bottom: 1px solid #e4e7ec; }
.version-layout { grid-template-columns: 1fr; }
.golden-form { grid-template-columns: 1fr; }
.retention-actions { flex-wrap: wrap; }
.retention-actions :deep(.el-button) { min-height: 40px; margin-left: 0; }
}
@media (max-width: 560px) {
.governance-page { padding: 12px; }
.summary-grid { grid-template-columns: 1fr; }
.summary-item { border-right: 0; border-bottom: 1px solid #e4e7ec; }
.summary-item:last-child { border-bottom: 0; }
.flags-panel, .golden-panel, .editor-panel, .versions-panel { padding: 14px; }
.retention-actions { display: grid; grid-template-columns: 1fr; }
.retention-actions :deep(.el-button) { width: 100%; }
}
</style>

View File

@ -238,7 +238,7 @@ const reviewSaving = ref(false)
const pdfPreviewUrl = computed(() => reviewTarget.value ? pptAdminApi.getManualFileUrl(reviewTarget.value.id) : '')
//
let pollTimers: Record<string, ReturnType<typeof setTimeout>> = {}
const pollTimers: Record<string, ReturnType<typeof setTimeout>> = {}
const MAX_POLL_DURATION = 7 * 60 * 1000 // 6
onMounted(async () => {

View File

@ -217,23 +217,6 @@ function onPosterProviderChange(val: string) {
}
}
// base_url
const MODEL_BASE_URLS: Record<string, string> = {
'deepseek': 'https://api.deepseek.com/v1',
'minimax': 'https://api.minimax.chat/v1',
'gemini': 'https://generativelanguage.googleapis.com/v1/models',
'gpt': 'https://api.openai.com/v1',
'claude': 'https://api.anthropic.com/v1',
}
function inferBaseUrl(model: string): string {
const lower = model.toLowerCase()
for (const [keyword, url] of Object.entries(MODEL_BASE_URLS)) {
if (lower.startsWith(keyword)) return url
}
return ''
}
async function handleSyncModels() {
syncingModels.value = true
try {

View File

@ -17,13 +17,15 @@
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="imageUrl" class="pdf-preview__page">
<img
v-else-if="imageUrl"
:src="imageUrl"
:alt="`第 ${currentPage} 页`"
class="pdf-preview__image"
@load="loading = false"
/>
<span v-if="highlightStyle" class="pdf-preview__highlight" :style="highlightStyle" aria-hidden="true" />
</div>
<div v-else class="pdf-preview__empty">
<span>无法加载 PDF 页面</span>
</div>
@ -32,7 +34,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, nextTick } from 'vue'
import { computed, ref, watch, onMounted, nextTick } from 'vue'
import { ArrowLeft, ArrowRight, Loading } from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
@ -41,6 +43,7 @@ const props = defineProps<{
extractionIndex: number
pageNumber?: number
pdfName?: string
bbox?: number[] | null
}>()
const emit = defineEmits<{
@ -49,9 +52,23 @@ const emit = defineEmits<{
const currentPage = ref(props.pageNumber || 1)
const pageCount = ref(0)
const pageSizes = ref<Array<{ pageNumber: number; width: number; height: number }>>([])
const loading = ref(true)
const imageUrl = ref('')
const bodyRef = ref<HTMLElement>()
const highlightStyle = computed(() => {
if (!Array.isArray(props.bbox) || props.bbox.length !== 4) return null
const page = pageSizes.value.find(item => item.pageNumber === currentPage.value)
if (!page?.width || !page?.height) return null
const [x0, y0, x1, y1] = props.bbox.map(Number)
if (![x0, y0, x1, y1].every(Number.isFinite) || x1 <= x0 || y1 <= y0) return null
return {
left: `${Math.max(0, Math.min(100, x0 / page.width * 100))}%`,
top: `${Math.max(0, Math.min(100, y0 / page.height * 100))}%`,
width: `${Math.max(0, Math.min(100, (x1 - x0) / page.width * 100))}%`,
height: `${Math.max(0, Math.min(100, (y1 - y0) / page.height * 100))}%`,
}
})
// PDF
async function loadPdfInfo() {
@ -59,8 +76,10 @@ async function loadPdfInfo() {
try {
const res: any = await pptApi.getPdfInfo(props.sessionId, props.extractionIndex)
pageCount.value = res?.data?.pageCount || 0
pageSizes.value = res?.data?.pageSizes || []
} catch {
pageCount.value = 0
pageSizes.value = []
}
}
@ -152,10 +171,27 @@ onMounted(() => {
.pdf-preview__image {
max-width: 100%;
height: auto;
display: block;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.pdf-preview__page {
position: relative;
display: inline-block;
max-width: 100%;
line-height: 0;
}
.pdf-preview__highlight {
position: absolute;
border: 2px solid #c93636;
border-radius: 3px;
background: rgba(255, 214, 10, .2);
box-shadow: 0 2px 8px rgba(140, 35, 35, .2);
pointer-events: none;
}
.pdf-preview__loading,
.pdf-preview__empty {
display: flex;

View File

@ -6,7 +6,26 @@
<p>正在校验数据...</p>
</div>
<div v-else-if="loadError" class="loading-state" role="alert">
<strong>复核数据加载失败</strong>
<p>{{ loadError }}</p>
<el-button type="primary" @click="loadReviewData">重新加载</el-button>
</div>
<template v-else>
<ReviewStatusSummary
class="review-summary"
:status="errorCount > 0 ? 'blocked' : 'reviewing'"
:description="reviewSummaryDescription"
:items="reviewSummaryItems"
/>
<nav v-if="issues.length" class="issue-navigation" aria-label="校验问题定位">
<button v-for="(issue, index) in issues.slice(0, 8)" :key="`${issue.field}-${index}`" type="button" @click="navigateToIssue(issue)">
<span :class="issue.severity === 'error' ? 'is-error' : 'is-warning'">{{ issue.severity === 'error' ? '阻断' : '提醒' }}</span>
{{ issue.message }}
</button>
<small v-if="issues.length > 8">另有 {{ issues.length - 8 }} 请按产品标签逐项核对</small>
</nav>
<!-- 核对主体产品切换 + 宽数据编辑区 -->
<div class="review-body">
<!-- 左栏产品列表 -->
@ -441,6 +460,43 @@
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`证据原文 · ${currentEvidence.length}`" name="evidence">
<div v-if="currentEvidence.length" class="evidence-layout">
<div class="evidence-list" role="list" aria-label="字段证据">
<button
v-for="(item, index) in currentEvidence"
:key="`${item.fieldPath}-${index}`"
type="button"
class="evidence-item"
:class="{ active: selectedEvidenceIndex === index }"
@click="selectedEvidenceIndex = index"
>
<span class="evidence-item__heading">
<strong>{{ evidenceFieldLabel(item.fieldPath) }}</strong>
<el-tag size="small" :type="item.status === 'confirmed' ? 'success' : 'info'" effect="plain">
{{ item.status === 'confirmed' ? '已确认' : '自动提取' }}
</el-tag>
</span>
<span class="evidence-item__snippet">{{ item.snippet || '原文片段不可用' }}</span>
<small> {{ item.evidence?.pageNumber || '—' }} · {{ bboxLabel(item.evidence?.bbox) }}</small>
</button>
</div>
<div class="evidence-preview">
<PdfPagePreview
:session-id="sessionId"
:extraction-index="selectedIdx"
:page-number="selectedEvidence?.evidence?.pageNumber || 1"
:pdf-name="currentExt.pdfName"
:bbox="selectedEvidence?.evidence?.bbox || null"
/>
<p class="evidence-location" aria-live="polite">
当前字段{{ evidenceFieldLabel(selectedEvidence?.fieldPath) }}定位 {{ bboxLabel(selectedEvidence?.evidence?.bbox) }}
</p>
</div>
</div>
<el-empty v-else description="当前解析结果没有可验证的原文证据,关键金额将由服务端门禁处理" :image-size="64" />
</el-tab-pane>
</el-tabs>
<el-empty v-if="currentExt.status === 'error'" :description="currentExt.error || '解析失败'" />
@ -494,6 +550,15 @@ import { ref, computed, onMounted, onBeforeUnmount, inject, nextTick, watch } fr
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
import ReviewStatusSummary from '@/components/generation/ReviewStatusSummary.vue'
import PdfPagePreview from '@/pages/components/ppt/PdfPagePreview.vue'
type ReviewStatusItem = {
key: string
label: string
value: number | string
tone?: 'neutral' | 'success' | 'warning' | 'danger'
}
// PptPage
const autoSave = inject<{ scheduleSave: (data: Record<string, any>) => void; registerFlush?: (fn: () => Promise<void>) => void } | null>('pptAutoSave', null)
@ -518,6 +583,7 @@ const emit = defineEmits<{
}>()
const loading = ref(true)
const loadError = ref('')
const saving = ref(false)
const confirming = ref(false)
const tableReadonly = ref(true) //
@ -526,6 +592,7 @@ const issues = ref<Array<{ field: string; severity: string; message: string; ext
const originalJson = ref('')
const selectedIdx = ref(0)
const activeTab = ref('fields')
const selectedEvidenceIndex = ref(0)
const AUTO_SAVE_DELAY = 1000
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
let autoSaveReady = false
@ -581,6 +648,8 @@ const validationStatus = computed(() => {
})
const currentExt = computed(() => extractions.value[selectedIdx.value] || null)
const currentEvidence = computed<any[]>(() => currentExt.value?.evidence || [])
const selectedEvidence = computed(() => currentEvidence.value[selectedEvidenceIndex.value] || null)
const currentIssues = computed(() =>
issues.value.filter(issue => issueBelongsToProduct(issue, selectedIdx.value))
)
@ -594,6 +663,33 @@ const manualChangeCount = computed(() => changedExtractions.value.reduce(
(total, ext) => total + new Set((ext.modificationLog || []).map((item: any) => item.path)).size,
0,
))
const evidenceCount = computed(() => extractions.value.reduce(
(total, ext) => total + (Array.isArray(ext.evidence) ? ext.evidence.length : 0),
0,
))
const conflictCount = computed(() => issues.value.filter(item => (
String((item as any).code || '').toLowerCase().includes('conflict')
|| String(item.message || '').includes('冲突')
)).length)
const missingCount = computed(() => issues.value.filter(item => (
String((item as any).code || '').toLowerCase().includes('missing')
|| String(item.message || '').includes('缺失')
|| String(item.message || '').includes('未识别')
)).length)
const derivedCount = computed(() => extractions.value.reduce((total, ext) => {
const provenance = ext?.data?.meta?.provenance || ext?.provenance || {}
return total + Object.values(provenance).filter((item: any) => item?.source === 'system_derived').length
}, 0))
const reviewSummaryItems = computed<ReviewStatusItem[]>(() => [
{ key: 'evidence', label: '证据定位', value: evidenceCount.value, tone: 'success' },
{ key: 'missing', label: '缺失', value: missingCount.value, tone: missingCount.value ? 'danger' : 'neutral' },
{ key: 'conflict', label: '冲突', value: conflictCount.value, tone: conflictCount.value ? 'danger' : 'neutral' },
{ key: 'derived', label: '系统推导', value: derivedCount.value, tone: derivedCount.value ? 'warning' : 'neutral' },
{ key: 'manual', label: '人工覆盖', value: manualChangeCount.value, tone: manualChangeCount.value ? 'warning' : 'neutral' },
])
const reviewSummaryDescription = computed(() => errorCount.value
? `${errorCount.value} 个阻断项,必须修正后才能创建确认快照。`
: `当前有 ${warnCount.value} 个非阻断提醒;确认后将生成不可变快照。`)
const hasMissingOverrideReason = computed(() => changedExtractions.value.some(
ext => !String(ext.overrideReason || '').trim(),
))
@ -666,10 +762,44 @@ function tabLabel(label: string, section: 'fields' | 'benefit' | 'withdrawal'):
function selectProduct(idx: number) {
selectedIdx.value = idx
selectedEvidenceIndex.value = 0
activeTab.value = 'fields'
}
onMounted(async () => {
async function navigateToIssue(issue: any) {
const productIndex = issue.extractionIndex ?? extractions.value.findIndex((_: any, index: number) => issueBelongsToProduct(issue, index))
if (productIndex >= 0) selectedIdx.value = productIndex
activeTab.value = issueSection(issue)
await nextTick()
const selector = issue.severity === 'error' ? '.field-has-error' : '.field-has-warn'
const target = document.querySelector<HTMLElement>(`.ppt-data-review ${selector}`)
target?.scrollIntoView({ block: 'center', behavior: 'smooth' })
target?.querySelector<HTMLElement>('input, textarea, button, [tabindex]')?.focus()
}
function evidenceFieldLabel(path = '') {
const labels: Record<string, string> = {
product_name: '产品名称',
'insured.age': '被保人年龄',
'insured.gender': '被保人性别',
'policy.currency': '币种',
'policy.sum_insured': '保额',
'policy.annual_premium': '年缴保费',
'policy.premium_payment_period': '缴费年期',
}
if (labels[path]) return labels[path]
if (path.startsWith('benefit.')) return `利益表 · ${path.split('.').slice(-2).join(' / ')}`
return path || '未知字段'
}
function bboxLabel(bbox: unknown) {
if (!Array.isArray(bbox) || bbox.length !== 4) return '页面原文'
return `坐标 ${bbox.map(value => Number(value).toFixed(0)).join(', ')}`
}
async function loadReviewData() {
loading.value = true
loadError.value = ''
try {
const [sessionRes, validateRes]: any[] = await Promise.all([
pptApi.getSession(props.sessionId),
@ -699,13 +829,15 @@ onMounted(async () => {
const validateData = validateRes?.data
issues.value = validateData?.issues || []
} catch (e: any) {
ElMessage.error('加载数据失败: ' + (e?.message || '未知错误'))
loadError.value = e?.response?.data?.message || e?.message || '请检查网络后重试。'
} finally {
loading.value = false
await nextTick()
autoSaveReady = true
}
})
}
onMounted(loadReviewData)
function getKeyMetrics(ext: any) {
const d = ext.data
@ -1134,6 +1266,28 @@ async function handleConfirm() {
gap: 10px;
}
.review-summary { flex-shrink: 0; }
.issue-navigation { display: flex; gap: 6px; overflow-x: auto; padding: 0 0 2px; flex-shrink: 0; }
.issue-navigation button { flex: 0 0 auto; max-width: min(360px, 76vw); min-height: 34px; padding: 6px 10px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; color: #475467; text-align: start; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.issue-navigation button:hover { border-color: #98a2b3; }
.issue-navigation button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.issue-navigation button span { margin-inline-end: 5px; font-weight: 700; }
.issue-navigation .is-error { color: #b42318; }
.issue-navigation .is-warning { color: #9a6700; }
.issue-navigation small { align-self: center; flex: 0 0 auto; color: #667085; }
.evidence-layout { display: grid; grid-template-columns: minmax(240px, .75fr) minmax(320px, 1.25fr); gap: 12px; min-height: 420px; }
.evidence-list { display: grid; align-content: start; gap: 6px; max-height: 520px; overflow-y: auto; }
.evidence-item { width: 100%; display: grid; gap: 6px; padding: 10px 12px; border: 1px solid #d7e0ea; border-radius: 8px; background: #fff; color: #344054; text-align: start; cursor: pointer; }
.evidence-item:hover { border-color: #98a2b3; }
.evidence-item.active { border-color: #3b7a57; background: #f4f8f5; }
.evidence-item:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.evidence-item__heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.evidence-item__snippet { color: #475467; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.evidence-item small { color: #667085; }
.evidence-preview { min-width: 0; display: grid; grid-template-rows: minmax(360px, 1fr) auto; gap: 8px; }
.evidence-location { margin: 0; color: #667085; font-size: 12px; overflow-wrap: anywhere; }
/* ===== 核对主体 ===== */
.review-body {
display: flex;
@ -1405,6 +1559,9 @@ async function handleConfirm() {
.review-editor {
min-height: 400px;
}
.evidence-layout { grid-template-columns: 1fr; }
.evidence-list { max-height: 260px; }
}
/* 字段级校验:问题直接贴在数据上,不再占用独立侧栏 */

View File

@ -6,6 +6,15 @@
<h4>选择模板</h4>
<el-tag type="success" effect="plain" size="small">{{ scenarioLabel(currentScenario) || '通用' }}</el-tag>
</div>
<el-alert
v-if="configurationError"
:title="configurationError"
type="error"
:closable="false"
show-icon
>
<template #default><el-button link type="primary" :loading="loadingOptions" @click="loadConfiguration">重新加载配置</el-button></template>
</el-alert>
<el-empty
v-if="!loadingOptions && availableTemplates.length === 0"
description="没有适用于当前保司和产品的 PPT 模板,请先到后台配置"
@ -55,11 +64,18 @@
</div>
<div class="config-row" v-if="scenarioMatchTrace">
<span class="config-label">解析方式</span>
<span class="config-value">服务端快照规则 · {{ scenarioMatchTrace.planTypes?.join(' + ') || '未识别' }}</span>
<span class="config-value">{{ scenarioResolverLabel }}</span>
</div>
<div class="config-row" v-if="resolvedScenarioVersion">
<span class="config-label">场景版本</span>
<span class="config-value">v{{ resolvedScenarioVersion.version }} · #{{ resolvedScenarioVersion.id }}</span>
</div>
<div class="config-row" v-if="selectedTemplate">
<span class="config-label">选用模板</span>
<span class="config-value highlight">{{ selectedTemplate.name || selectedTemplate.id }}</span>
<span class="config-value highlight">
{{ selectedTemplate.name || selectedTemplate.id }}
<small v-if="selectedTemplate.assetVersion">· 资产 v{{ selectedTemplate.assetVersion }}</small>
</span>
</div>
<div class="config-row">
<span class="config-label">计划书数量</span>
@ -75,6 +91,16 @@
</div>
</div>
<details v-if="scenarioMatchTrace" class="match-trace">
<summary>为什么匹配这个场景</summary>
<p>{{ matchTraceSummary }}</p>
<ul v-if="scenarioMatchChecks.length">
<li v-for="(check, index) in scenarioMatchChecks" :key="index">
{{ check.message || check.rule || check.code || JSON.stringify(check) }}
</li>
</ul>
</details>
<!-- 生成进度 / 操作 -->
<div v-if="taskStatus === 'queued' || taskStatus === 'running'" class="task-progress">
<el-icon class="spinning"><Loading /></el-icon>
@ -85,7 +111,7 @@
</div>
<div v-else-if="taskStatus === 'failed'" class="task-failed">
<el-alert :title="taskError || '生成失败'" type="error" show-icon :closable="false" />
<el-button type="primary" size="large" :loading="generating" :disabled="!selectedTemplateId" @click="handleGenerate" style="width: 100%; margin-top: 12px">
<el-button type="primary" size="large" :loading="generating" :disabled="!selectedTemplateId || !currentScenario || !!configurationError" @click="handleGenerate" style="width: 100%; margin-top: 12px">
重新生成
</el-button>
</div>
@ -94,7 +120,7 @@
type="primary"
size="large"
:loading="generating"
:disabled="!selectedTemplateId"
:disabled="!selectedTemplateId || !currentScenario || !!configurationError"
@click="handleGenerate"
class="gen-btn"
>
@ -134,7 +160,9 @@ const companies = ref<any[]>([])
const loadingOptions = ref(true)
const generating = ref(false)
const resolvedScenario = ref('')
const resolvedScenarioVersion = ref<any | null>(null)
const scenarioMatchTrace = ref<Record<string, any> | null>(null)
const configurationError = ref('')
//
watch(selectedTemplateId, () => {
@ -172,6 +200,19 @@ const selectedTemplate = computed(() =>
templates.value.find(t => t.id === selectedTemplateId.value) || null
)
const currentScenario = computed(() => resolvedScenario.value)
const scenarioMatchChecks = computed(() => scenarioMatchTrace.value?.checks || [])
const scenarioResolverLabel = computed(() => {
const resolver = scenarioMatchTrace.value?.resolver
const planTypes = scenarioMatchTrace.value?.planTypes?.join(' + ')
if (resolver === 'published-scenario-resolver-v2') return `已发布规则${planTypes ? ` · ${planTypes}` : ''}`
return `服务端快照规则${planTypes ? ` · ${planTypes}` : ''}`
})
const matchTraceSummary = computed(() => {
const trace = scenarioMatchTrace.value
if (!trace) return ''
const detected = trace.detectedScenario || trace.finalScenario || currentScenario.value
return `根据 ${sessionFiles.value.length} 份计划书及险种组合,服务端检测为“${scenarioLabel(detected) || detected}”,最终使用“${scenarioLabel(trace.finalScenario || currentScenario.value) || trace.finalScenario || currentScenario.value}”。`
})
const availableTemplates = computed(() => templates.value.filter(template => {
if (
!template.scenarioTag
@ -262,7 +303,9 @@ function planTypeLabel(t: string) {
return map[t] || t || ''
}
onMounted(async () => {
async function loadConfiguration() {
loadingOptions.value = true
configurationError.value = ''
try {
const [optionsRes, sessionRes]: any[] = await Promise.all([
pptApi.getRenderOptions(),
@ -274,6 +317,7 @@ onMounted(async () => {
const snapshotIds = sessionRes?.data?.snapshot_ids || []
const scenarioRes: any = await pptApi.resolveScenario(snapshotIds)
resolvedScenario.value = scenarioRes?.data?.scenario || ''
resolvedScenarioVersion.value = scenarioRes?.data?.scenarioVersion || null
scenarioMatchTrace.value = scenarioRes?.data?.matchTrace || null
const savedOptions = sessionRes?.data?.draft_options || {}
@ -305,11 +349,13 @@ onMounted(async () => {
}
}
} catch (e: any) {
ElMessage.error(e?.message || '配置加载失败')
configurationError.value = e?.response?.data?.message || e?.message || '配置加载失败,请重试。'
} finally {
loadingOptions.value = false
}
})
}
onMounted(loadConfiguration)
//
watch(availableTemplates, (list) => {
@ -335,7 +381,7 @@ async function handleGenerate() {
emit('generated')
}
} catch (e: any) {
ElMessage.error(e?.message || '生成失败')
ElMessage.error(e?.response?.data?.message || e?.message || '生成失败')
} finally {
generating.value = false
}
@ -566,6 +612,33 @@ onUnmounted(() => {
font-weight: 600;
}
.config-value small {
color: #667085;
font-size: 11px;
font-weight: 500;
}
.match-trace {
padding-top: 12px;
border-top: 1px solid #e4e7ec;
color: #344054;
font-size: 12px;
}
.match-trace summary {
cursor: pointer;
font-weight: 650;
}
.match-trace summary:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 3px;
border-radius: 3px;
}
.match-trace p { margin: 8px 0; line-height: 1.55; }
.match-trace ul { margin: 0; padding-inline-start: 18px; }
/* ===== 生成进度 ===== */
.task-progress {
display: flex;

View File

@ -107,7 +107,7 @@ const props = defineProps<{
task?: any | null
}>()
const emit = defineEmits<{
defineEmits<{
parsed: []
back: []
}>()

View File

@ -126,7 +126,7 @@
:title="slide.hidden ? '恢复显示' : '隐藏此页'"
@click.stop="toggleSlideHidden(idx)"
>
<el-icon :size="12"><component :is="slide.hidden ? 'View' : 'Hide'" /></el-icon>
<el-icon :size="12"><component :is="slide.hidden ? View : Hide" /></el-icon>
</button>
</span>
</div>
@ -355,7 +355,7 @@ import {
View, Hide, Refresh,
} from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
import type { SlidesJson, SlideData, SlideShape, SlideParagraph, QualityReport } from '@/utils/ppt-api'
import type { SlidesJson, SlideShape, SlideParagraph, QualityReport } from '@/utils/ppt-api'
import PptSlideCanvas from './PptSlideCanvas.vue'
const props = defineProps<{

View File

@ -32,7 +32,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import type { SlideData, SlideShape, SlideParagraph } from '@/utils/ppt-api'
import type { SlideData, SlideShape } from '@/utils/ppt-api'
const props = defineProps<{
slide: SlideData | null
@ -206,7 +206,7 @@ function renderText(ctx: CanvasRenderingContext2D, shape: SlideShape) {
const lineHeight = fontSize * 1.4
const words = para.text.split('')
let line = ''
let lineX = shape.x + 4
const lineX = shape.x + 4
for (const char of words) {
const testLine = line + char

View File

@ -226,7 +226,7 @@ const router = createRouter({
routes,
})
router.beforeEach(async (to, _from) => {
router.beforeEach(async (to) => {
document.title = `${(to.meta as any).title || ''} - 保险智能客服`
// 企微 OAuth 回调:在路由匹配之前提取 token 和 user 参数