已完成本轮剩余前端功能开发,计划书中的前端核心任务目前没有功能性遗留。
主要完成: 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:
parent
2422303b36
commit
11f9b108db
@ -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:
|
||||
|
||||
BIN
backups/baodan_before_migrate_037_fix.dump
Normal file
BIN
backups/baodan_before_migrate_037_fix.dump
Normal file
Binary file not shown.
84
docs/PPT与海报Phase4至6补充实施记录_20260802.md
Normal file
84
docs/PPT与海报Phase4至6补充实施记录_20260802.md
Normal file
@ -0,0 +1,84 @@
|
||||
# PPT 与海报 Phase 4~6 补充实施记录
|
||||
|
||||
> 日期: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 0~6 的主要代码任务并具备 M5 能力及 M6 基础,但 M2、M4、M6 的正式验收仍受真实样本、业务签字、模板资产和生产观察约束。
|
||||
|
||||
## 5. 本轮验证基线
|
||||
|
||||
- 后端计划相关专项回归:107 passed,1 skipped;
|
||||
- 前端 TypeScript 检查:通过;
|
||||
- 前端生产构建:通过;
|
||||
- 治理页与海报页静态 UI 审查:无阻断项;
|
||||
- 仓库全量 `tests/` 中另有 2 个与本轮无关的既有失败,详见交付说明,不能计入本轮回归失败。
|
||||
@ -5,6 +5,8 @@
|
||||
> 适用范围:计划书 PDF 解析、人工复核、PlanData、海报生成、PPT 生成、场景与模板、异步任务、质量门禁、测试、监控与数据留存
|
||||
> 文档性质:实施主计划。既有 PPT、海报和 PDF 专项计划继续作为局部设计参考;若与本计划冲突,以“统一事实快照、字段证据链、失败关闭、版本可追溯”四项原则为准。
|
||||
|
||||
> 2026-08-02 实施说明:计划中的代码基础、治理接口和失败关闭门禁已推进至 Phase 6;实际里程碑仍须以 30 份获批金标准、真实模板试跑、业务公式签字和灰度观察结果验收。详见 `docs/PPT与海报Phase4至6补充实施记录_20260802.md`。
|
||||
|
||||
## 1. 结论与实施决策
|
||||
|
||||
本次整改不能按“海报修一套、PPT 再修一套”的方式推进。审计报告中的 55 项问题共享四个根因:事实来源分裂、字段语义失真、配置运行时失效、任务与输出不可复现。因此,修复主线确定为:
|
||||
|
||||
15
frontend/eslint.config.mjs
Normal file
15
frontend/eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
140
frontend/src/components/generation/GenerationTracePanel.vue
Normal file
140
frontend/src/components/generation/GenerationTracePanel.vue
Normal 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>
|
||||
119
frontend/src/components/generation/ReviewStatusSummary.vue
Normal file
119
frontend/src/components/generation/ReviewStatusSummary.vue
Normal 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>
|
||||
@ -90,7 +90,7 @@ const props = defineProps<{
|
||||
sections?: Array<{ id: string; visible: boolean }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
edit: [field: string, value: string]
|
||||
}>()
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
// 新会话创建后刷新会话列表
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -156,7 +156,7 @@ async function onRegister() {
|
||||
|
||||
// 跳转到对话页
|
||||
router.push('/chat')
|
||||
} catch (error: any) {
|
||||
} catch {
|
||||
// 错误已由 api 拦截器处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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', field.label),
|
||||
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>
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -17,13 +17,15 @@
|
||||
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="`第 ${currentPage} 页`"
|
||||
class="pdf-preview__image"
|
||||
@load="loading = false"
|
||||
/>
|
||||
<div v-else-if="imageUrl" class="pdf-preview__page">
|
||||
<img
|
||||
: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;
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
/* 字段级校验:问题直接贴在数据上,不再占用独立侧栏 */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -107,7 +107,7 @@ const props = defineProps<{
|
||||
task?: any | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
parsed: []
|
||||
back: []
|
||||
}>()
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 参数
|
||||
|
||||
Loading…
Reference in New Issue
Block a user