调整UI布局

This commit is contained in:
wsb1224 2026-07-31 17:20:26 +08:00
parent 82217c0d35
commit 4c42260a29
12 changed files with 847 additions and 606 deletions

View File

@ -80,20 +80,21 @@ const isGenerating = computed(() =>
const canAct = computed(() => {
if (isGenerating.value) return false
if (props.actionState === 'generate-poster' && !props.canGenerate) return false
return props.actionState !== 'generating'
})
</script>
<style scoped>
.poster-action-bar {
height: 56px;
padding: 0 20px;
min-height: 64px;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-top: 1px solid var(--poster-border);
background: #fff;
background: rgb(255 255 255 / 97%);
flex-shrink: 0;
}
@ -119,15 +120,15 @@ const canAct = computed(() => {
}
.dot {
width: 8px;
height: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #e5e7eb;
transition: background 0.2s;
}
.dot.done {
background: #16a34a;
background: var(--poster-accent);
}
.action-buttons {
@ -137,13 +138,45 @@ const canAct = computed(() => {
flex-shrink: 0;
}
.action-buttons :deep(.el-button) {
min-height: 38px;
border-radius: 10px;
}
.action-buttons :deep(.el-button--primary) {
min-width: 112px;
--el-button-bg-color: var(--poster-accent);
--el-button-border-color: var(--poster-accent);
--el-button-hover-bg-color: var(--poster-accent-hover);
--el-button-hover-border-color: var(--poster-accent-hover);
}
@media (max-width: 767px) {
.status-text {
display: none;
}
.poster-action-bar {
padding: 0 12px;
min-height: 68px;
padding: 8px 12px max(8px, env(safe-area-inset-bottom));
}
.progress-dots {
display: none;
}
.action-status {
display: none;
}
.action-buttons {
width: 100%;
justify-content: flex-end;
overflow-x: auto;
}
.action-buttons :deep(.el-button--primary) {
flex: 1;
}
}
</style>

View File

@ -2,6 +2,7 @@
<aside class="config-rail">
<div class="rail-scroll">
<PosterProductPanel
ref="productPanelRef"
:product-id="draft.productId"
:product-source-type="draft.productSourceType"
:product-source-id="draft.productSourceId"
@ -13,6 +14,7 @@
<el-divider class="panel-divider" />
<PosterSourcePanel
ref="sourcePanelRef"
:product-id="draft.productId"
:product-source-type="draft.productSourceType"
:product-source-id="draft.productSourceId"
@ -32,6 +34,7 @@
<el-divider class="panel-divider" />
<PosterCreativePanel
ref="creativePanelRef"
:scenario="draft.scenario"
:output-mode="draft.outputMode"
:export-size="draft.exportSize"
@ -54,12 +57,17 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { PosterDraft } from '@/composables/usePosterWorkspace'
import PosterProductPanel from './PosterProductPanel.vue'
import PosterSourcePanel from './PosterSourcePanel.vue'
import PosterCreativePanel from './PosterCreativePanel.vue'
import PosterReferencePanel from './PosterReferencePanel.vue'
const productPanelRef = ref<InstanceType<typeof PosterProductPanel> | null>(null)
const sourcePanelRef = ref<InstanceType<typeof PosterSourcePanel> | null>(null)
const creativePanelRef = ref<InstanceType<typeof PosterCreativePanel> | null>(null)
const props = defineProps<{ draft: PosterDraft }>()
const emit = defineEmits<{ 'update:draft': [patch: Partial<PosterDraft>] }>()
@ -96,6 +104,13 @@ function onProductSourceUpdate(source: {
} : {}),
})
}
defineExpose({
openProductSelector: () => productPanelRef.value?.openSelector(),
openSourceUpload: () => sourcePanelRef.value?.openUpload(),
openSourceReview: () => sourcePanelRef.value?.openReview(),
openTemplates: () => creativePanelRef.value?.openTemplates(),
})
</script>
<style scoped>
@ -103,22 +118,25 @@ function onProductSourceUpdate(source: {
width: 300px;
min-width: 280px;
max-width: 320px;
border-right: 1px solid var(--poster-border);
background: #fafbfc;
border: 1px solid var(--poster-border);
border-radius: var(--poster-radius);
background: var(--poster-surface);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--poster-shadow);
}
.rail-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
padding: 18px;
}
.panel-divider {
margin: 12px 0;
margin: 14px 0;
border-color: #edf1ee;
}
@media (max-width: 1023px) {

View File

@ -1,5 +1,5 @@
<template>
<div class="creative-panel">
<div ref="panelRoot" class="creative-panel">
<div class="panel-header" @click="expanded = !expanded">
<div class="header-left">
<el-icon :size="16"><Brush /></el-icon>
@ -200,6 +200,15 @@ const templates = ref<any[]>([])
const showCustom = ref(false)
const customW = ref(1024)
const customH = ref(1792)
const panelRoot = ref<HTMLElement | null>(null)
function openTemplates() {
expanded.value = true
panelRoot.value?.scrollIntoView({ block: 'start' })
if (templates.value.length > 6) drawerVisible.value = true
}
defineExpose({ openTemplates })
//
//

View File

@ -18,7 +18,7 @@
@update:copy-content="$emit('update:draft', { copyContent: $event })"
@update:ai-raw-content="$emit('update:draft', { aiRawContent: $event })"
@update:compliance="$emit('update:draft', {
complianceStatus: $event.status,
complianceStatus: normalizeComplianceStatus($event.status),
complianceIssues: $event.issues,
complianceRevision: $event.revision,
})"
@ -52,6 +52,17 @@ defineProps<{ draft: PosterDraft }>()
defineEmits<{ 'update:draft': [patch: Partial<PosterDraft>] }>()
const activeTab = ref('copy')
function openTab(tab: 'copy' | 'layout' | 'delivery') {
activeTab.value = tab
}
defineExpose({ openTab })
function normalizeComplianceStatus(status: string): PosterDraft['complianceStatus'] {
if (status === 'pass' || status === 'warn' || status === 'block') return status
return 'unchecked'
}
</script>
<style scoped>
@ -59,11 +70,13 @@ const activeTab = ref('copy')
width: 320px;
min-width: 300px;
max-width: 360px;
border-left: 1px solid var(--poster-border);
background: #fff;
border: 1px solid var(--poster-border);
border-radius: var(--poster-radius);
background: var(--poster-surface);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--poster-shadow);
}
.inspector-tabs {
@ -76,14 +89,23 @@ const activeTab = ref('copy')
.inspector-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 16px;
background: #fafbfc;
background: #f8faf8;
border-bottom: 1px solid var(--poster-border);
}
.inspector-tabs :deep(.el-tabs__content) {
flex: 1;
overflow-y: auto;
padding: 16px;
padding: 18px;
}
.inspector-tabs :deep(.el-tabs__item.is-active) {
color: var(--poster-accent);
font-weight: 600;
}
.inspector-tabs :deep(.el-tabs__active-bar) {
background-color: var(--poster-accent);
}
.inspector-tabs :deep(.el-tab-pane) {

View File

@ -161,6 +161,13 @@ const materialDialogVisible = ref(false)
const editingMaterial = ref<any | null>(null)
const manualUploadEnabled = ref(true)
function openSelector() {
expanded.value = true
drawerVisible.value = true
}
defineExpose({ openSelector })
const typeMap: Record<string, string> = {
savings: '储蓄',
ci: '重疾',

View File

@ -1,5 +1,5 @@
<template>
<div class="source-panel">
<div ref="panelRoot" class="source-panel">
<div class="panel-header" @click="expanded = !expanded">
<div class="header-left">
<el-icon :size="16"><Document /></el-icon>
@ -166,6 +166,20 @@ const expanded = ref(true)
const editingFields = ref(false)
const confirming = ref(false)
const localPassword = ref('')
const panelRoot = ref<HTMLElement | null>(null)
function openUpload() {
expanded.value = true
panelRoot.value?.scrollIntoView({ block: 'start' })
}
function openReview() {
expanded.value = true
editingFields.value = true
panelRoot.value?.scrollIntoView({ block: 'start' })
}
defineExpose({ openUpload, openReview })
const localFields = ref({
age: null, gender: null, currency: null, sum_assured: null,

View File

@ -118,7 +118,14 @@ const canvasStyle = computed(() => {
justify-content: center;
overflow: hidden;
position: relative;
background: #f5f6f8;
border: 1px solid var(--poster-border);
border-radius: var(--poster-radius);
background:
linear-gradient(90deg, rgb(23 107 77 / 3%) 1px, transparent 1px),
linear-gradient(rgb(23 107 77 / 3%) 1px, transparent 1px),
#edf2ef;
background-size: 24px 24px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 80%);
}
.stage-toolbar {
@ -126,18 +133,34 @@ const canvasStyle = computed(() => {
top: 12px;
right: 12px;
z-index: 10;
padding: 4px;
border: 1px solid var(--poster-border);
border-radius: 10px;
background: rgb(255 255 255 / 92%);
box-shadow: 0 8px 22px rgb(24 61 44 / 10%);
}
.stage-canvas {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
padding: 32px;
width: 100%;
height: 100%;
overflow: auto;
}
@media (max-width: 767px) {
.stage-canvas {
padding: 18px 10px 24px;
}
.stage-toolbar {
top: 8px;
right: 8px;
}
}
/* ── 加载状态 ──────────────────────── */
.canvas-loading {
position: absolute;
@ -200,3 +223,8 @@ const canvasStyle = computed(() => {
box-shadow: 0 4px 16px rgb(15 23 42 / 12%);
}
</style>
padding: 4px;
border: 1px solid var(--poster-border);
border-radius: 10px;
background: rgb(255 255 255 / 92%);
box-shadow: 0 8px 22px rgb(24 61 44 / 10%);

View File

@ -78,14 +78,15 @@ function onCommand(cmd: string) {
<style scoped>
.poster-toolbar {
height: 56px;
padding: 0 20px;
height: 68px;
min-height: 68px;
padding: 0 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--poster-border);
background: #fff;
background: rgb(255 255 255 / 97%);
flex-shrink: 0;
}
@ -102,8 +103,9 @@ function onCommand(cmd: string) {
}
.title-input :deep(.el-input__inner) {
font-size: 15px;
font-weight: 600;
font-size: 17px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--poster-text);
border: none;
padding: 0;
@ -113,12 +115,12 @@ function onCommand(cmd: string) {
.title-input :deep(.el-input__wrapper) {
box-shadow: none !important;
padding: 4px 8px;
border-radius: 4px;
border-radius: 9px;
}
.title-input :deep(.el-input__wrapper:hover),
.title-input :deep(.el-input__wrapper:focus-within) {
background: #f5f7fa;
background: var(--poster-accent-light);
}
.save-indicator {
@ -152,4 +154,40 @@ function onCommand(cmd: string) {
gap: 4px;
flex-shrink: 0;
}
.toolbar-right :deep(.el-button) {
min-height: 36px;
border-radius: 10px;
color: var(--poster-muted);
}
.toolbar-right :deep(.el-button:hover) {
color: var(--poster-accent);
background: var(--poster-accent-light);
}
@media (max-width: 767px) {
.poster-toolbar {
height: 56px;
min-height: 56px;
padding: 0 12px;
}
.title-input {
max-width: min(48vw, 220px);
}
.title-input :deep(.el-input__inner) {
font-size: 15px;
}
.toolbar-right :deep(.el-button span) {
display: none;
}
.toolbar-right :deep(.el-button) {
width: 36px;
padding: 8px;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="poster-workspace" :class="{ 'mobile-mode': isMobile }">
<div ref="workspaceRef" class="poster-workspace" :class="{ 'mobile-mode': isMobile }">
<!-- 顶部工具栏 -->
<PosterToolbar
:title="posterTitle || ws.draft.value.productName || '未命名海报'"
@ -23,6 +23,7 @@
<!-- 左栏配置区 -->
<PosterConfigRail
ref="configRailRef"
v-show="!isMobile || mobileTab === 'config'"
:draft="ws.draft.value"
@update:draft="onDraftUpdate"
@ -41,7 +42,9 @@
<!-- 右栏文案与发布三栏模式直接显示双栏模式隐藏 -->
<PosterInspector
v-if="!hideInspector"
v-if="isMobile || !hideInspector"
ref="inlineInspectorRef"
v-show="!isMobile || mobileTab === 'inspector'"
:draft="ws.draft.value"
@update:draft="onDraftUpdate"
/>
@ -67,6 +70,7 @@
:z-index="100"
>
<PosterInspector
ref="drawerInspectorRef"
:draft="ws.draft.value"
@update:draft="onDraftUpdate"
/>
@ -113,26 +117,33 @@ const router = useRouter()
const route = useRoute()
const ws = usePosterWorkspace()
const { isMobile } = useMobile()
const workspaceRef = ref<HTMLElement | null>(null)
const stageRef = ref<InstanceType<typeof PosterStage> | null>(null)
const configRailRef = ref<InstanceType<typeof PosterConfigRail> | null>(null)
const inlineInspectorRef = ref<InstanceType<typeof PosterInspector> | null>(null)
const drawerInspectorRef = ref<InstanceType<typeof PosterInspector> | null>(null)
// productName
const posterTitle = ref('')
const showTasksDrawer = ref(false)
const showInspectorDrawer = ref(false)
const mobileTab = ref<'config' | 'stage' | 'inspector'>('stage')
const mobileTab = ref<'config' | 'stage' | 'inspector'>('config')
//
const workspaceWidth = ref(window.innerWidth - 220)
const workspaceWidth = ref(0)
let workspaceObserver: ResizeObserver | null = null
function updateWorkspaceWidth() {
workspaceWidth.value = window.innerWidth - 220
}
onMounted(() => {
if (!workspaceRef.value) return
workspaceWidth.value = workspaceRef.value.clientWidth
workspaceObserver = new ResizeObserver(entries => {
workspaceWidth.value = entries[0]?.contentRect.width || workspaceRef.value?.clientWidth || 0
})
workspaceObserver.observe(workspaceRef.value)
})
onMounted(() => window.addEventListener('resize', updateWorkspaceWidth))
onBeforeUnmount(() => window.removeEventListener('resize', updateWorkspaceWidth))
const hideInspector = computed(() => workspaceWidth.value < 900)
const hideInspector = computed(() => workspaceWidth.value > 0 && workspaceWidth.value < 900)
// 稿
function onDraftUpdate(patch: Record<string, any>) {
@ -160,53 +171,64 @@ async function onAction() {
switch (state) {
case 'select-product':
// tab
if (isMobile.value) mobileTab.value = 'config'
// ""
ElMessage.info('请点击左栏"产品资料"中的"选择产品"按钮')
await nextTick()
configRailRef.value?.openProductSelector()
break
case 'upload-data':
if (isMobile.value) mobileTab.value = 'config'
ElMessage.info('请在左栏"计划书与数据"中上传计划书')
await nextTick()
configRailRef.value?.openSourceUpload()
break
case 'confirm-data':
if (isMobile.value) mobileTab.value = 'config'
ElMessage.info('请在左栏"计划书与数据"中确认解析数据')
await nextTick()
configRailRef.value?.openSourceReview()
break
case 'select-template':
if (isMobile.value) mobileTab.value = 'config'
ElMessage.info('请在左栏"场景与模板"中选择海报模板')
await nextTick()
configRailRef.value?.openTemplates()
break
case 'generate-copy':
// / tab
if (hideInspector.value) {
showInspectorDrawer.value = true
}
if (isMobile.value) mobileTab.value = 'inspector'
ElMessage.info('请在右栏"文案"标签中生成文案')
await openInspectorTab('copy')
break
case 'confirm-compliance':
// / tab
if (hideInspector.value) {
showInspectorDrawer.value = true
}
if (isMobile.value) mobileTab.value = 'inspector'
//
await nextTick()
ElMessage.info('请在"发布"标签中勾选合规确认')
await openInspectorTab('delivery')
break
case 'generate-poster':
await onGenerate()
break
case 'download-poster':
await onDownload()
break
}
}
async function openInspectorTab(tab: 'copy' | 'layout' | 'delivery') {
if (isMobile.value) {
mobileTab.value = 'inspector'
await nextTick()
inlineInspectorRef.value?.openTab(tab)
return
}
if (hideInspector.value) {
showInspectorDrawer.value = true
await nextTick()
drawerInspectorRef.value?.openTab(tab)
return
}
await nextTick()
inlineInspectorRef.value?.openTab(tab)
}
//
let pollTimer: ReturnType<typeof setInterval> | null = null
let documentSaveTimer: ReturnType<typeof setTimeout> | null = null
@ -661,6 +683,8 @@ watch(
)
onBeforeUnmount(() => {
workspaceObserver?.disconnect()
workspaceObserver = null
stopPolling()
if (documentSaveTimer) clearTimeout(documentSaveTimer)
if (ws.draft.value.posterUrl) {
@ -675,32 +699,42 @@ onBeforeUnmount(() => {
<style scoped>
/* ── 主题变量 ───────────────────────── */
.poster-workspace {
--poster-accent: #3b7a57;
--poster-accent-light: #f0f7f3;
--poster-border: #e5eaf3;
--poster-text: #172033;
--poster-muted: #667085;
--poster-accent: #176b4d;
--poster-accent-hover: #10583f;
--poster-accent-light: #eaf5ef;
--poster-border: #dfe7e2;
--poster-text: #17251e;
--poster-muted: #66756d;
--poster-surface: #fff;
--poster-bg: #f3f7f4;
--poster-radius: 14px;
--poster-shadow: 0 12px 34px rgb(24 61 44 / 8%);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
background: var(--poster-bg);
position: relative;
color-scheme: light;
container-type: inline-size;
}
/* ── 工作区主体:三栏 grid ────────────── */
.workspace-body {
flex: 1;
display: grid;
grid-template-columns: minmax(240px, 280px) 1fr minmax(260px, 320px);
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(280px, 320px);
gap: 12px;
padding: 12px;
overflow: hidden;
min-height: 0;
}
/* ── 隐藏第三栏inspector在双栏模式 ── */
.workspace-body.hide-inspector {
grid-template-columns: minmax(240px, 280px) 1fr;
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr);
}
/* ── 右侧栏浮动按钮 ────────────────── */
@ -711,39 +745,47 @@ onBeforeUnmount(() => {
z-index: 90;
width: 44px;
height: 44px;
box-shadow: 0 4px 16px rgb(59 122 87 / 30%);
box-shadow: 0 10px 28px rgb(23 107 77 / 24%);
}
/* ── 移动端标签页 ────────────────────── */
.mobile-tabs {
display: flex;
border-bottom: 1px solid var(--poster-border);
background: #fafbfc;
gap: 4px;
padding: 4px;
border: 1px solid var(--poster-border);
border-radius: 12px;
background: #e9efeb;
flex-shrink: 0;
}
.mobile-tabs button {
flex: 1;
padding: 10px;
min-height: 38px;
padding: 8px 10px;
border: none;
background: none;
font-size: 14px;
border-radius: 9px;
background: transparent;
font-size: 13px;
color: var(--poster-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
transition: background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.mobile-tabs button.active {
color: var(--poster-accent);
border-bottom-color: var(--poster-accent);
background: #fff;
font-weight: 600;
box-shadow: 0 2px 8px rgb(24 61 44 / 8%);
}
/* ── 移动端布局 ─────────────────────── */
.mobile-mode .workspace-body {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
gap: 8px;
padding: 8px;
padding-bottom: 8px;
}
.mobile-mode .workspace-body :deep(.config-rail),
@ -752,16 +794,25 @@ onBeforeUnmount(() => {
min-width: 0;
max-width: none;
border: none;
border-radius: var(--poster-radius);
}
.mobile-mode .workspace-body :deep(.config-rail) {
border-bottom: 1px solid var(--poster-border);
border: 1px solid var(--poster-border);
}
/* ── 双栏响应式 ──────────────────────── */
@media (max-width: 768px) {
.workspace-body:not(.mobile-tabs-visible) {
grid-template-columns: 1fr;
@container (max-width: 899px) {
.workspace-body {
grid-template-columns: 280px minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.poster-workspace *,
.poster-workspace *::before,
.poster-workspace *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>

View File

@ -37,21 +37,48 @@
<!-- ===== 工作区主体 ===== -->
<template v-else>
<div class="ppt-ws__main">
<!-- 移动端步骤导航为横向条桌面端通过 CSS 隐藏 -->
<!-- 移动端只展示当前步骤完整步骤放入抽屉避免小屏横向拥挤 -->
<div class="ppt-ws__mobile-nav">
<template v-for="(s, idx) in steps" :key="idx">
<button class="ppt-ws__mobile-current" type="button" @click="mobileStepsOpen = true">
<span class="ppt-ws__mobile-copy">
<span class="ppt-ws__mobile-meta">步骤 {{ currentStep + 1 }} / {{ steps.length }}</span>
<strong>{{ steps[currentStep].title }}</strong>
</span>
<span class="ppt-ws__mobile-open">
查看流程
<el-icon><ArrowDown /></el-icon>
</span>
</button>
<div class="ppt-ws__mobile-progress" aria-hidden="true">
<span :style="{ width: mobileProgress }" />
</div>
</div>
<el-drawer
v-model="mobileStepsOpen"
title="PPT 生成流程"
direction="btt"
size="auto"
class="ppt-ws__step-drawer"
>
<div class="ppt-ws__drawer-steps">
<button
class="ppt-ws__mobile-step"
v-for="(s, idx) in steps"
:key="idx"
type="button"
class="ppt-ws__drawer-step"
:class="{ active: idx === currentStep, done: idx < currentStep }"
:disabled="idx > maxAccessibleStep"
@click="goToStep(idx)"
>
<span class="ppt-ws__mobile-step-num">{{ idx + 1 }}</span>
<span class="ppt-ws__mobile-step-name">{{ s.title }}</span>
<span class="ppt-ws__drawer-num">{{ idx < currentStep ? '✓' : idx + 1 }}</span>
<span>
<strong>{{ s.title }}</strong>
<small>{{ s.description }}</small>
</span>
</button>
<span v-if="idx < steps.length - 1" class="ppt-ws__mobile-step-sep" :class="{ done: idx < currentStep }" />
</template>
</div>
</el-drawer>
<!-- 左侧步骤导航桌面端 -->
<PptWorkspaceNav
@ -74,7 +101,7 @@
</div>
<!-- 底部操作栏 -->
<PptWorkspaceActions :current-step="currentStep" back-label="返回上一步" @back="goToStep(currentStep - 1)">
<PptWorkspaceActions v-if="showWorkspaceActions" :current-step="currentStep" back-label="返回上一步" @back="goToStep(currentStep - 1)">
<template #right>
<span v-if="sessionId" class="ppt-ws__step-hint">
{{ currentStep + 1 }} / {{ steps.length }} · {{ steps[currentStep].title }}
@ -84,61 +111,61 @@
</div>
<!-- 右侧上下文面板仅大屏可通过关闭按钮隐藏核对步骤自动隐藏进入专注模式 -->
<aside v-if="sessionId && rightPanelOpen && currentStep !== 2" class="ppt-ws__aside">
<div class="ppt-ws__aside-header">
<span>上下文</span>
<el-icon class="ppt-ws__aside-close" @click="rightPanelOpen = false"><Close /></el-icon>
</div>
<!-- <aside v-if="sessionId && rightPanelOpen && currentStep !== 2" class="ppt-ws__aside">-->
<!-- <div class="ppt-ws__aside-header">-->
<!-- <span>上下文</span>-->
<!-- <el-icon class="ppt-ws__aside-close" @click="rightPanelOpen = false"><Close /></el-icon>-->
<!-- </div>-->
<!-- 步骤 0上传 文件信息暂无 session 时不显示 -->
<div v-if="currentStep === 0" class="ppt-ws__aside-section">
<p class="ppt-ws__aside-hint">上传文件后此处显示文件摘要和配置信息</p>
</div>
<!-- &lt;!&ndash; 步骤 0上传 文件信息暂无 session 时不显示 &ndash;&gt;-->
<!-- <div v-if="currentStep === 0" class="ppt-ws__aside-section">-->
<!-- <p class="ppt-ws__aside-hint">上传文件后此处显示文件摘要和配置信息</p>-->
<!-- </div>-->
<!-- 步骤 1解析 解析进度 -->
<div v-else-if="currentStep === 1" class="ppt-ws__aside-section">
<h4>解析进度</h4>
<el-progress :percentage="ws.parseProgress.value" :stroke-width="6" />
<p class="ppt-ws__aside-status">{{ ws.serverStatus.value || '等待中' }}</p>
</div>
<!-- &lt;!&ndash; 步骤 1解析 解析进度 &ndash;&gt;-->
<!-- <div v-else-if="currentStep === 1" class="ppt-ws__aside-section">-->
<!-- <h4>解析进度</h4>-->
<!-- <el-progress :percentage="ws.parseProgress.value" :stroke-width="6" />-->
<!-- <p class="ppt-ws__aside-status">{{ ws.serverStatus.value || '等待中' }}</p>-->
<!-- </div>-->
<!-- 步骤 2数据核对 校验状态 -->
<div v-else-if="currentStep === 2" class="ppt-ws__aside-section">
<h4>校验状态</h4>
<div class="ppt-ws__aside-stat">
<span class="stat-label">错误</span>
<span class="stat-value" :class="{ error: stepErrors[2] > 0 }">{{ stepErrors[2] }}</span>
</div>
<div class="ppt-ws__aside-stat">
<span class="stat-label">警告</span>
<span class="stat-value" :class="{ warn: stepWarnings[2] > 0 }">{{ stepWarnings[2] }}</span>
</div>
</div>
<!-- &lt;!&ndash; 步骤 2数据核对 校验状态 &ndash;&gt;-->
<!-- <div v-else-if="currentStep === 2" class="ppt-ws__aside-section">-->
<!-- <h4>校验状态</h4>-->
<!-- <div class="ppt-ws__aside-stat">-->
<!-- <span class="stat-label">错误</span>-->
<!-- <span class="stat-value" :class="{ error: stepErrors[2] > 0 }">{{ stepErrors[2] }}</span>-->
<!-- </div>-->
<!-- <div class="ppt-ws__aside-stat">-->
<!-- <span class="stat-label">警告</span>-->
<!-- <span class="stat-value" :class="{ warn: stepWarnings[2] > 0 }">{{ stepWarnings[2] }}</span>-->
<!-- </div>-->
<!-- </div>-->
<!-- 步骤 3生成 配置摘要 -->
<div v-else-if="currentStep === 3" class="ppt-ws__aside-section">
<h4>生成配置</h4>
<div class="ppt-ws__aside-stat">
<span class="stat-label">任务 ID</span>
<span class="stat-value mono">{{ ws.latestTaskId.value || '—' }}</span>
</div>
</div>
<!-- &lt;!&ndash; 步骤 3生成 配置摘要 &ndash;&gt;-->
<!-- <div v-else-if="currentStep === 3" class="ppt-ws__aside-section">-->
<!-- <h4>生成配置</h4>-->
<!-- <div class="ppt-ws__aside-stat">-->
<!-- <span class="stat-label">任务 ID</span>-->
<!-- <span class="stat-value mono">{{ ws.latestTaskId.value || '—' }}</span>-->
<!-- </div>-->
<!-- </div>-->
<!-- 步骤 4结果 质量状态 -->
<div v-else-if="currentStep === 4" class="ppt-ws__aside-section">
<h4>质量状态</h4>
<p class="ppt-ws__aside-hint">详情请查看右侧质量检查面板</p>
</div>
</aside>
<!-- &lt;!&ndash; 步骤 4结果 质量状态 &ndash;&gt;-->
<!-- <div v-else-if="currentStep === 4" class="ppt-ws__aside-section">-->
<!-- <h4>质量状态</h4>-->
<!-- <p class="ppt-ws__aside-hint">详情请查看右侧质量检查面板</p>-->
<!-- </div>-->
<!-- </aside>-->
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, provide, onUnmounted } from 'vue'
import { computed, onMounted, ref, provide, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Bell, Clock, RefreshLeft, ArrowLeft, Close } from '@element-plus/icons-vue'
import { Bell, Clock, RefreshLeft, ArrowLeft, ArrowDown, Close } from '@element-plus/icons-vue'
import { usePptWorkspace } from '@/composables/useWorkspace'
import { useAutoSave } from '@/composables/useAutoSave'
import DraftIndicator from '@/components/DraftIndicator.vue'
@ -156,6 +183,7 @@ const ws = usePptWorkspace()
const { sessionId, currentStep } = ws
const activeSection = ref<'workspace' | 'tasks'>('workspace')
const rightPanelOpen = ref(true)
const mobileStepsOpen = ref(false)
//
const autoSave = useAutoSave({ endpoint: () => `/workspace/ppt/workspaces/${sessionId.value}/draft` })
@ -205,6 +233,8 @@ const steps = [
const stepErrors = ref<number[]>([0, 0, 0, 0, 0])
const stepWarnings = ref<number[]>([0, 0, 0, 0, 0])
const maxAccessibleStep = ref(0)
const mobileProgress = computed(() => `${((currentStep.value + 1) / steps.length) * 100}%`)
const showWorkspaceActions = computed(() => currentStep.value !== 0 && currentStep.value !== 2)
//
onMounted(async () => {
@ -223,6 +253,7 @@ function toggleSection() {
function goToStep(idx: number) {
if (idx >= 0 && idx <= maxAccessibleStep.value) {
ws.goToStep(idx)
mobileStepsOpen.value = false
}
}
@ -267,23 +298,27 @@ function resetAll() {
<style scoped>
/* ===== CSS 自定义属性 ===== */
.ppt-ws {
--ws-accent: #3B7A57;
--ws-accent-soft: #f0f7f3;
--ws-border: #e5e7eb;
--ws-text: #1a2e23;
--ws-muted: #6b7280;
--ws-bg: #f5f5f5;
--ws-accent: #176b4d;
--ws-accent-hover: #10583f;
--ws-accent-soft: #eaf5ef;
--ws-border: #dfe7e2;
--ws-text: #17251e;
--ws-muted: #66756d;
--ws-bg: #f3f7f4;
--ws-panel: #fff;
--ws-radius: 12px;
--ws-radius-sm: 8px;
--ws-radius: 14px;
--ws-radius-sm: 10px;
--ws-shadow: 0 12px 34px rgb(24 61 44 / 8%);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--ws-bg);
font-size: 14px;
color: var(--ws-text);
container-type: inline-size;
color-scheme: light;
}
/* ===== 顶栏 ===== */
@ -292,12 +327,14 @@ function resetAll() {
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 20px;
height: 64px;
min-height: 64px;
background: var(--ws-panel);
padding: 0 22px;
height: 68px;
min-height: 68px;
background: rgb(255 255 255 / 96%);
border-bottom: 1px solid var(--ws-border);
flex-shrink: 0;
position: relative;
z-index: 5;
}
.ppt-ws__toolbar-left {
@ -308,13 +345,17 @@ function resetAll() {
}
.ppt-ws__back {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 18px;
color: var(--ws-muted);
flex-shrink: 0;
padding: 4px;
border-radius: 6px;
transition: background 0.15s;
width: 34px;
height: 34px;
border-radius: 10px;
transition: background 0.16s ease, color 0.16s ease;
}
.ppt-ws__back:hover {
@ -323,8 +364,9 @@ function resetAll() {
}
.ppt-ws__title {
font-size: 16px;
font-weight: 600;
font-size: 17px;
font-weight: 650;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -338,12 +380,26 @@ function resetAll() {
flex-shrink: 0;
}
.ppt-ws__toolbar-right :deep(.el-button) {
min-height: 36px;
border-radius: 10px;
}
.ppt-ws__toolbar-right :deep(.el-button--primary) {
--el-button-bg-color: var(--ws-accent);
--el-button-border-color: var(--ws-accent);
--el-button-hover-bg-color: var(--ws-accent-hover);
--el-button-hover-border-color: var(--ws-accent-hover);
}
/* ===== 主区域(三栏) ===== */
.ppt-ws__main {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
gap: 12px;
padding: 12px;
}
/* ===== 中间区域 ===== */
@ -353,8 +409,10 @@ function resetAll() {
flex-direction: column;
min-width: 0;
background: var(--ws-panel);
border-left: 1px solid var(--ws-border);
border-right: 1px solid var(--ws-border);
border: 1px solid var(--ws-border);
border-radius: var(--ws-radius);
box-shadow: var(--ws-shadow);
overflow: hidden;
}
.ppt-ws__content {
@ -380,7 +438,9 @@ function resetAll() {
width: 240px;
flex-shrink: 0;
background: var(--ws-panel);
border-left: 1px solid var(--ws-border);
border: 1px solid var(--ws-border);
border-radius: var(--ws-radius);
box-shadow: var(--ws-shadow);
overflow-y: auto;
display: flex;
flex-direction: column;
@ -390,7 +450,7 @@ function resetAll() {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
padding: 15px 16px;
font-size: 13px;
font-weight: 600;
color: var(--ws-muted);
@ -464,6 +524,131 @@ function resetAll() {
display: none;
}
.ppt-ws__mobile-current {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0;
border: 0;
color: var(--ws-text);
background: transparent;
text-align: left;
}
.ppt-ws__mobile-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.ppt-ws__mobile-copy strong {
font-size: 15px;
}
.ppt-ws__mobile-meta {
color: var(--ws-muted);
font-size: 11px;
}
.ppt-ws__mobile-open {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--ws-accent);
font-size: 12px;
font-weight: 600;
}
.ppt-ws__mobile-progress {
width: 100%;
height: 3px;
margin-top: 10px;
overflow: hidden;
border-radius: 3px;
background: #e4ece7;
}
.ppt-ws__mobile-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--ws-accent);
transition: width 0.2s ease;
}
:global(.ppt-ws__step-drawer) {
border-radius: 18px 18px 0 0;
}
:global(.ppt-ws__step-drawer .el-drawer__header) {
margin-bottom: 8px;
padding: 18px 18px 8px;
color: #17251e;
font-weight: 650;
}
:global(.ppt-ws__step-drawer .el-drawer__body) {
padding: 8px 16px max(18px, env(safe-area-inset-bottom));
}
.ppt-ws__drawer-steps {
display: grid;
gap: 6px;
}
.ppt-ws__drawer-step {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 12px;
width: 100%;
padding: 10px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--ws-text);
text-align: left;
}
.ppt-ws__drawer-step:disabled {
opacity: 0.42;
}
.ppt-ws__drawer-step.active {
border-color: #b8d8c7;
background: var(--ws-accent-soft);
}
.ppt-ws__drawer-step > span:last-child {
display: flex;
flex-direction: column;
}
.ppt-ws__drawer-step small {
margin-top: 2px;
color: var(--ws-muted);
}
.ppt-ws__drawer-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 10px;
background: #edf2ef;
color: var(--ws-muted);
font-weight: 700;
}
.ppt-ws__drawer-step.active .ppt-ws__drawer-num,
.ppt-ws__drawer-step.done .ppt-ws__drawer-num {
background: var(--ws-accent);
color: #fff;
}
/* ===== 任务中心铺满 ===== */
.ppt-ws__tasks {
flex: 1;
@ -486,15 +671,12 @@ function resetAll() {
}
.ppt-ws__mobile-nav {
display: flex;
align-items: center;
gap: 0;
padding: 10px 12px;
display: block;
padding: 11px 14px 10px;
background: var(--ws-panel);
border-bottom: 1px solid var(--ws-border);
overflow-x: auto;
border: 1px solid var(--ws-border);
border-radius: 12px;
flex-shrink: 0;
-webkit-overflow-scrolling: touch;
}
.ppt-ws__center {
@ -507,78 +689,36 @@ function resetAll() {
min-height: 56px;
}
.ppt-ws__toolbar-right .el-button span {
.ppt-ws__toolbar-right :deep(.el-button span) {
display: none;
}
.ppt-ws__toolbar-right {
gap: 4px;
}
.ppt-ws__toolbar-right :deep(.el-button) {
width: 36px;
margin-left: 0;
padding: 8px;
}
.ppt-ws__title {
max-width: min(34vw, 180px);
font-size: 15px;
}
.ppt-ws__main {
gap: 8px;
padding: 8px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
.ppt-ws__content {
padding: 12px;
}
}
.ppt-ws__mobile-step {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
font-size: 12px;
color: var(--ws-muted);
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.ppt-ws__mobile-step:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ppt-ws__mobile-step.active {
background: var(--ws-accent-soft);
color: var(--ws-accent);
font-weight: 600;
}
.ppt-ws__mobile-step.done {
color: var(--ws-accent);
}
.ppt-ws__mobile-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #e8efe9;
font-size: 11px;
font-weight: 600;
}
.ppt-ws__mobile-step.active .ppt-ws__mobile-step-num {
background: var(--ws-accent);
color: #fff;
}
.ppt-ws__mobile-step.done .ppt-ws__mobile-step-num {
background: #d1fae5;
color: var(--ws-accent);
}
.ppt-ws__mobile-step-sep {
width: 12px;
height: 1px;
background: var(--ws-border);
flex-shrink: 0;
}
.ppt-ws__mobile-step-sep.done {
background: var(--ws-accent);
}
/* ===== 兼容无 container query 的浏览器fallback 媒体查询 ===== */
@supports not (container-type: inline-size) {
@media (max-width: 959px) {
@ -588,18 +728,26 @@ function resetAll() {
.ppt-ws__main { flex-direction: column; }
.ppt-ws__main > :first-child:not(.ppt-ws__center) { display: none; }
.ppt-ws__mobile-nav {
display: flex;
align-items: center;
gap: 0;
padding: 10px 12px;
display: block;
padding: 11px 14px 10px;
background: var(--ws-panel);
border-bottom: 1px solid var(--ws-border);
overflow-x: auto;
border: 1px solid var(--ws-border);
border-radius: 12px;
flex-shrink: 0;
}
.ppt-ws__center { border: none; }
.ppt-ws__toolbar { padding: 0 12px; height: 56px; min-height: 56px; }
.ppt-ws__main { gap: 8px; padding: 8px; }
.ppt-ws__content { padding: 12px; }
}
}
@media (prefers-reduced-motion: reduce) {
.ppt-ws *,
.ppt-ws *::before,
.ppt-ws *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>

View File

@ -7,25 +7,7 @@
</div>
<template v-else>
<!-- 顶部摘要条 -->
<div class="review-topbar">
<div class="topbar-status" :class="validationStatus">
<span class="status-icon">{{ statusMark }}</span>
<span class="status-text">{{ statusTitle }}</span>
<el-tag :type="validationTagType" size="small" effect="plain">{{ validationLabel }}</el-tag>
</div>
<div class="topbar-metrics">
<span class="topbar-metric">{{ extractions.length }} 产品</span>
<span class="topbar-metric" :class="{ error: errorCount > 0 }">{{ errorCount }} 错误</span>
<span class="topbar-metric" :class="{ warn: warnCount > 0 }">{{ warnCount }} 警告</span>
<span class="topbar-metric">{{ totalRows }} 行数据</span>
<el-button size="small" :type="showPdfPreview ? 'primary' : 'default'" plain @click="showPdfPreview = !showPdfPreview">
{{ showPdfPreview ? '隐藏 PDF' : '显示 PDF' }}
</el-button>
</div>
</div>
<!-- 三栏主体 -->
<!-- 核对主体产品切换 + 宽数据编辑区 -->
<div class="review-body">
<!-- 左栏产品列表 -->
<aside class="review-products">
@ -55,18 +37,7 @@
</button>
</aside>
<!-- 中栏PDF 预览可折叠 -->
<div v-if="showPdfPreview && currentExt" class="review-pdf">
<PdfPagePreview
:session-id="props.sessionId"
:extraction-index="selectedIdx"
:page-number="previewPage"
:pdf-name="currentExt.pdfName"
@page-change="previewPage = $event"
/>
</div>
<!-- 中栏当前产品编辑区 -->
<!-- 当前产品编辑区 -->
<div class="review-editor" v-if="currentExt">
<div class="editor-header">
<el-tag :type="typeTagMap[currentExt.planType] || 'info'" size="small">
@ -88,37 +59,67 @@
<!-- 标签页切换 -->
<el-tabs v-model="activeTab" class="editor-tabs">
<el-tab-pane label="关键字段" name="fields">
<el-tab-pane :label="tabLabel('关键字段', 'fields')" name="fields">
<el-form label-position="top" size="default" class="fields-form">
<div class="fields-grid">
<el-form-item label="产品名称">
<el-form-item
label="产品名称"
:class="fieldIssueClass('product_name', 'product name')"
:data-issue="fieldIssueMessage('product_name', 'product name')"
>
<el-input v-model="currentExt.data.product_name" />
</el-form-item>
<el-form-item label="产品类型">
<el-form-item
label="产品类型"
:class="fieldIssueClass('product_type', 'product type')"
:data-issue="fieldIssueMessage('product_type', 'product type')"
>
<el-select v-model="currentExt.data.product_type" style="width: 100%">
<el-option label="储蓄险" value="savings" />
<el-option label="重疾险" value="ci" />
<el-option label="IUL" value="iul" />
</el-select>
</el-form-item>
<el-form-item label="被保人年龄">
<el-form-item
label="被保人年龄"
:class="fieldIssueClass('insured.age', 'age')"
:data-issue="fieldIssueMessage('insured.age', 'age')"
>
<el-input-number v-model="currentExt.data.insured.age" :min="0" :max="120" />
</el-form-item>
<el-form-item label="被保人性别">
<el-form-item
label="被保人性别"
:class="fieldIssueClass('insured.gender', 'gender')"
:data-issue="fieldIssueMessage('insured.gender', 'gender')"
>
<el-select v-model="currentExt.data.insured.gender" style="width: 100%">
<el-option label="男" value="male" />
<el-option label="女" value="female" />
<el-option label="未知" value="unknown" />
</el-select>
</el-form-item>
<el-form-item label="吸烟状态" :class="{ 'field-missing': currentExt.data.insured.smoker === 'unknown' }">
<el-form-item
label="吸烟状态"
:class="[
{ 'field-missing': currentExt.data.insured.smoker === 'unknown' },
fieldIssueClass('insured.smoker', 'smoker'),
]"
:data-issue="fieldIssueMessage('insured.smoker', 'smoker')"
>
<el-select v-model="currentExt.data.insured.smoker" style="width: 100%">
<el-option label="吸烟" value="yes" />
<el-option label="不吸烟" value="no" />
<el-option label="未知(可继续生成)" value="unknown" />
</el-select>
</el-form-item>
<el-form-item label="币种" :class="{ 'field-missing': !currentExt.data.policy.currency }">
<el-form-item
label="币种"
:class="[
{ 'field-missing': !currentExt.data.policy.currency },
fieldIssueClass('policy.currency', 'currency'),
]"
:data-issue="fieldIssueMessage('policy.currency', 'currency')"
>
<el-select v-model="currentExt.data.policy.currency" filterable clearable style="width: 100%">
<el-option label="美元USD" value="USD" />
<el-option label="港币HKD" value="HKD" />
@ -128,13 +129,28 @@
<el-option label="英镑GBP" value="GBP" />
</el-select>
</el-form-item>
<el-form-item label="年缴保费">
<el-form-item
label="年缴保费"
:class="fieldIssueClass('annual_premium', 'annual premium')"
:data-issue="fieldIssueMessage('annual_premium', 'annual premium')"
>
<el-input-number v-model="currentExt.data.policy.annual_premium" :min="0" :step="1000" />
</el-form-item>
<el-form-item label="缴费年期">
<el-form-item
label="缴费年期"
:class="fieldIssueClass('premium_payment_period', 'payment period')"
:data-issue="fieldIssueMessage('premium_payment_period', 'payment period')"
>
<el-input-number v-model="currentExt.data.policy.premium_payment_period" :min="1" :max="50" />
</el-form-item>
<el-form-item label="保额" :class="{ 'field-missing': !currentExt.data.policy.sum_insured }">
<el-form-item
label="保额"
:class="[
{ 'field-missing': !currentExt.data.policy.sum_insured },
fieldIssueClass('sum_insured', 'sum assured'),
]"
:data-issue="fieldIssueMessage('sum_insured', 'sum assured')"
>
<el-input-number v-model="currentExt.data.policy.sum_insured" :min="0" :step="10000" placeholder="请输入保额" />
</el-form-item>
<el-form-item label="基本计划年保费">
@ -150,7 +166,7 @@
</el-form>
</el-tab-pane>
<el-tab-pane :label="`利益演示(${getBenefitRows(currentExt).length} 行)`" name="benefit">
<el-tab-pane :label="tabLabel(`利益演示 · ${getBenefitRows(currentExt).length} 行`, 'benefit')" name="benefit">
<div class="table-toolbar">
<el-button size="small" type="primary" plain @click="addBenefitRow(currentExt)">新增年度</el-button>
<el-button size="small" plain @click="sortRows(getBenefitRows(currentExt))">按年度排序</el-button>
@ -251,7 +267,7 @@
</el-table>
</el-tab-pane>
<el-tab-pane :label="`退保提取(${getWithdrawalRows(currentExt).length} 行)`" name="withdrawal">
<el-tab-pane :label="tabLabel(`退保提取 · ${getWithdrawalRows(currentExt).length} 行`, 'withdrawal')" name="withdrawal">
<div class="table-toolbar">
<el-button size="small" type="primary" plain @click="addWithdrawalRow(currentExt)">新增退保行</el-button>
<el-button size="small" plain @click="sortRows(getWithdrawalRows(currentExt))">按年度排序</el-button>
@ -261,6 +277,7 @@
stripe border size="small"
max-height="420"
style="width: 100%"
:cell-class-name="getCellClass"
>
<el-table-column prop="policy_year" label="保单年度" width="86" fixed="left">
<template #default="{ row }">
@ -299,37 +316,6 @@
<el-empty v-if="currentExt.status === 'error'" :description="currentExt.error || '解析失败'" />
</div>
<!-- 右栏校验面板 -->
<aside class="review-issues">
<h4 class="panel-label">
校验状态
<el-tag v-if="errorCount > 0" type="danger" size="small" effect="plain">{{ errorCount }}</el-tag>
<el-tag v-else-if="warnCount > 0" type="warning" size="small" effect="plain">{{ warnCount }}</el-tag>
<el-tag v-else type="success" size="small" effect="plain">通过</el-tag>
</h4>
<div v-if="issues.length" class="issue-list">
<button
v-for="(issue, idx) in issues"
:key="idx"
type="button"
class="issue-item"
:class="issue.severity"
@click="jumpToIssue(issue)"
>
<el-icon v-if="issue.severity === 'error'" color="#dc2626"><CircleCloseFilled /></el-icon>
<el-icon v-else color="#b45309"><WarningFilled /></el-icon>
<span class="issue-text">
<span class="issue-field">{{ issue.field || '待确认' }}</span>
<span class="issue-msg">{{ issue.message }}</span>
</span>
</button>
</div>
<div v-else class="issues-clean">
<el-icon color="#16a34a" :size="20"><CircleCheckFilled /></el-icon>
<span>未发现阻断问题</span>
</div>
</aside>
</div>
<!-- 底部操作栏 -->
@ -352,11 +338,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, inject, watch, nextTick } from 'vue'
import { ref, computed, onMounted, inject, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { DataAnalysis, Loading, CircleCloseFilled, WarningFilled, CircleCheckFilled } from '@element-plus/icons-vue'
import { Loading } from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
import PdfPagePreview from './PdfPagePreview.vue'
// PptPage
const autoSave = inject<{ scheduleSave: (data: Record<string, any>) => void; registerFlush?: (fn: () => Promise<void>) => void } | null>('pptAutoSave', null)
@ -364,7 +349,9 @@ const autoSave = inject<{ scheduleSave: (data: Record<string, any>) => void; reg
// flush beforeunload
onMounted(() => {
if (autoSave?.registerFlush) {
autoSave.registerFlush(() => handleSave(true))
autoSave.registerFlush(async () => {
await handleSave(true)
})
}
})
@ -381,8 +368,6 @@ const emit = defineEmits<{
const loading = ref(true)
const saving = ref(false)
const tableReadonly = ref(true) //
const showPdfPreview = ref(true) // PDF
const previewPage = ref(1) // PDF
const extractions = ref<any[]>([])
const issues = ref<Array<{ field: string; severity: string; message: string; extractionIndex?: number; path?: string; section?: string; pdfName?: string }>>([])
const originalJson = ref('')
@ -411,34 +396,16 @@ const warnCount = computed(() => issues.value.filter(i => i.severity !== 'error'
watch([errorCount, warnCount], ([errors, warnings]) => {
emit('validation-update', { errors, warnings })
}, { immediate: true })
const totalRows = computed(() => extractions.value.reduce((sum, ext) => sum + getBenefitRows(ext).length, 0))
const validationStatus = computed(() => {
if (errorCount.value) return 'error'
if (warnCount.value) return 'warn'
return 'pass'
})
const validationTagType = computed(() => {
if (validationStatus.value === 'error') return 'danger'
if (validationStatus.value === 'warn') return 'warning'
return 'success'
})
const validationLabel = computed(() => {
if (validationStatus.value === 'error') return '有错误'
if (validationStatus.value === 'warn') return '需确认'
return '可生成'
})
const statusMark = computed(() => {
if (validationStatus.value === 'error') return '!'
if (validationStatus.value === 'warn') return '?'
return '✓'
})
const statusTitle = computed(() => {
if (validationStatus.value === 'error') return '存在阻断问题'
if (validationStatus.value === 'warn') return '可以继续,但建议先核对'
return '数据通过基础校验'
})
const currentExt = computed(() => extractions.value[selectedIdx.value] || null)
const currentIssues = computed(() =>
issues.value.filter(issue => issueBelongsToProduct(issue, selectedIdx.value))
)
const currentMetrics = computed(() => {
if (!currentExt.value) return []
return getKeyMetrics(currentExt.value)
@ -446,75 +413,76 @@ const currentMetrics = computed(() => {
const isDirty = computed(() => JSON.stringify(extractions.value) !== originalJson.value)
function productIssueCount(idx: number, severity: string): number {
// issues field
const ext = extractions.value[idx]
if (!ext) return 0
const name = ext.productName || ext.data?.product_name || ''
return issues.value.filter(i => {
if (i.severity !== severity) return false
if (name && i.field?.includes(name)) return true
if (i.field?.includes(`[${idx}]`)) return true
return false
const severityMatched = severity === 'error'
? i.severity === 'error'
: i.severity !== 'error'
return severityMatched && issueBelongsToProduct(i, idx)
}).length
}
function issueBelongsToProduct(issue: { field?: string; extractionIndex?: number }, idx: number): boolean {
if (issue.extractionIndex !== undefined) return issue.extractionIndex === idx
const ext = extractions.value[idx]
if (!ext) return false
const name = ext.productName || ext.data?.product_name || ''
if (name && issue.field?.includes(name)) return true
if (issue.field?.includes(`[${idx}]`)) return true
return extractions.value.length === 1
}
function findFieldIssue(...tokens: string[]) {
const normalizedTokens = tokens.map(token => token.toLowerCase())
return currentIssues.value.find(issue => {
const haystack = [issue.field, issue.path, issue.section, issue.message]
.filter(Boolean)
.join(' ')
.toLowerCase()
return normalizedTokens.some(token => haystack.includes(token))
})
}
function fieldIssueClass(...tokens: string[]): Record<string, boolean> {
const issue = findFieldIssue(...tokens)
return {
'field-has-error': issue?.severity === 'error',
'field-has-warn': !!issue && issue.severity !== 'error',
}
}
function fieldIssueMessage(...tokens: string[]): string {
return findFieldIssue(...tokens)?.message || ''
}
function issueSection(issue: { field?: string; path?: string; section?: string; message?: string }): 'fields' | 'benefit' | 'withdrawal' {
const haystack = [issue.field, issue.path, issue.section, issue.message]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (haystack.includes('withdrawal') || haystack.includes('提取')) return 'withdrawal'
if (
haystack.includes('benefit')
|| haystack.includes('利益')
|| haystack.includes('surrender')
|| haystack.includes('cash_value')
) return 'benefit'
return 'fields'
}
function sectionIssueCount(section: 'fields' | 'benefit' | 'withdrawal'): number {
return currentIssues.value.filter(issue => issueSection(issue) === section).length
}
function tabLabel(label: string, section: 'fields' | 'benefit' | 'withdrawal'): string {
const count = sectionIssueCount(section)
return count ? `${label} · ${count} 项待核对` : label
}
function selectProduct(idx: number) {
selectedIdx.value = idx
activeTab.value = 'fields'
}
function jumpToIssue(issue: { field: string; severity: string; message: string; extractionIndex?: number; section?: string; path?: string }) {
// 使 extractionIndex
if (issue.extractionIndex !== undefined && issue.extractionIndex >= 0 && issue.extractionIndex < extractions.value.length) {
selectedIdx.value = issue.extractionIndex
} else {
// 退
for (let i = 0; i < extractions.value.length; i++) {
const ext = extractions.value[i]
const name = ext.productName || ext.data?.product_name || ''
if (name && issue.field?.includes(name)) {
selectedIdx.value = i
break
}
}
}
// 使 section tab
const section = issue.section || ''
const field = issue.field || ''
if (section === 'benefitRows' || field.includes('benefit') || field.includes('退保') || field.includes('surrender') || field.includes('premium')) {
activeTab.value = 'benefit'
} else if (section === 'withdrawalRows' || field.includes('withdrawal') || field.includes('提取')) {
activeTab.value = 'withdrawal'
} else {
activeTab.value = 'fields'
}
// path
if (issue.path) {
nextTick(() => {
const target = document.querySelector(`[data-field="${issue.path}"]`)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('field-highlight')
setTimeout(() => target.classList.remove('field-highlight'), 2000)
}
})
}
// PDF benefitRows[path] source_page
if (showPdfPreview.value) {
const rowMatch = issue.path?.match(/benefitRows\[(\d+)\]/)
if (rowMatch) {
const rowIdx = parseInt(rowMatch[1])
const rows = currentExt.value?.data?.benefit_illustration || []
if (rows[rowIdx]?.source_page) {
previewPage.value = rows[rowIdx].source_page
}
}
}
}
onMounted(async () => {
try {
const [sessionRes, validateRes]: any[] = await Promise.all([
@ -601,11 +569,27 @@ function formatMoney(value: any): string {
return num.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
}
function getCellClass({ row, column }: { row: any; column: any }): string {
function getCellClass({ row, column, rowIndex }: { row: any; column: any; rowIndex: number }): string {
const field = column.property
if (!field) return ''
const val = row[field]
if (val == null || val === '') return 'cell-issue-missing'
const sectionTokens = activeTab.value === 'withdrawal'
? ['withdrawal', '提取']
: ['benefit', '利益', 'surrender']
const issue = currentIssues.value.find(item => {
const haystack = [item.field, item.path, item.section, item.message]
.filter(Boolean)
.join(' ')
.toLowerCase()
const sectionMatched = sectionTokens.some(token => haystack.includes(token))
const fieldMatched = haystack.includes(String(field).toLowerCase())
const rowMatched = !item.path || item.path.includes(`[${rowIndex}]`)
return sectionMatched && fieldMatched && rowMatched
})
if (issue?.severity === 'error') return 'cell-issue-error'
if (issue) return 'cell-issue-warn'
return ''
}
@ -840,83 +824,30 @@ async function handleConfirm() {
<style scoped>
.ppt-data-review {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
/* ===== 顶部摘要条 ===== */
.review-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e5e7eb;
flex-shrink: 0;
}
.topbar-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-icon {
width: 26px;
height: 26px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: #3B7A57;
font-size: 14px;
font-weight: 800;
flex-shrink: 0;
}
.topbar-status.error .status-icon { background: #dc2626; }
.topbar-status.warn .status-icon { background: #d97706; }
.topbar-status.pass .status-icon { background: #16a34a; }
.status-text {
font-size: 14px;
font-weight: 600;
color: #1a2e23;
}
.topbar-metrics {
display: flex;
gap: 16px;
}
.topbar-metric {
font-size: 13px;
color: #6b7280;
}
.topbar-metric.error { color: #dc2626; font-weight: 600; }
.topbar-metric.warn { color: #d97706; font-weight: 600; }
/* ===== 三栏主体 ===== */
/* ===== 核对主体 ===== */
.review-body {
display: flex;
gap: 12px;
gap: 10px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ===== 左栏:产品列表 ===== */
.review-products {
width: 200px;
width: 190px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
padding: 10px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
@ -925,11 +856,9 @@ async function handleConfirm() {
.panel-label {
margin: 0 0 8px;
font-size: 12px;
font-size: 13px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #47564e;
display: flex;
align-items: center;
gap: 6px;
@ -956,8 +885,8 @@ async function handleConfirm() {
}
.product-item.active {
background: #f0f7f3;
border-color: #3B7A57;
background: #eaf5ef;
border-color: #176b4d;
font-weight: 600;
}
@ -1003,21 +932,14 @@ async function handleConfirm() {
.product-badge.error { background: #fef2f2; color: #dc2626; }
.product-badge.warn { background: #fffbeb; color: #d97706; }
/* ===== PDF 预览面板 ===== */
.review-pdf {
width: 360px;
min-width: 280px;
flex-shrink: 0;
}
/* ===== 中栏:编辑区 ===== */
/* ===== 数据编辑区 ===== */
.review-editor {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
min-width: 0;
padding: 16px;
padding: 16px 18px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
@ -1040,7 +962,7 @@ async function handleConfirm() {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0;
padding: 4px 0 8px;
}
.metric-chip {
@ -1073,8 +995,8 @@ async function handleConfirm() {
.fields-form .fields-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px 16px;
}
.table-toolbar {
@ -1083,82 +1005,6 @@ async function handleConfirm() {
margin-bottom: 8px;
}
/* ===== 右栏:校验面板 ===== */
.review-issues {
width: 240px;
flex-shrink: 0;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.issue-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.issue-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 6px 8px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 12px;
transition: background 0.12s;
width: 100%;
}
.issue-item:hover {
background: #f9fafb;
}
.issue-item.error { background: #fef2f240; }
.issue-item.error:hover { background: #fef2f2; }
.issue-item.warn { background: #fffbeb40; }
.issue-item.warn:hover { background: #fffbeb; }
.issue-item .el-icon {
flex-shrink: 0;
margin-top: 1px;
}
.issue-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.issue-field {
font-weight: 600;
color: #374151;
}
.issue-msg {
color: #6b7280;
line-height: 1.4;
}
.issues-clean {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 8px;
color: #16a34a;
font-size: 13px;
font-weight: 500;
}
/* ===== 加载态 ===== */
.loading-state {
display: flex;
@ -1190,8 +1036,7 @@ async function handleConfirm() {
/* ===== 响应式 ===== */
@media (max-width: 1100px) {
.review-issues { width: 200px; }
.review-products { width: 170px; }
.review-products { width: 180px; }
}
@media (max-width: 900px) {
@ -1214,48 +1059,53 @@ async function handleConfirm() {
padding: 6px 12px;
}
.review-issues {
width: 100%;
max-height: 200px;
}
.review-editor {
min-height: 400px;
}
}
/* 缺失字段高亮 */
.field-missing :deep(.el-form-item__label) {
color: #f56c6c;
/* 字段级校验:问题直接贴在数据上,不再占用独立侧栏 */
.field-missing :deep(.el-form-item__label),
.field-has-error :deep(.el-form-item__label) {
color: #c93636;
font-weight: 600;
}
.field-missing :deep(.el-input-number) {
border-color: #f56c6c;
.field-has-warn :deep(.el-form-item__label) {
color: #a65a00;
font-weight: 600;
}
/* 问题跳转字段高亮动画 */
.field-highlight {
animation: highlight-pulse 2s ease-out;
.field-missing :deep(.el-input__wrapper),
.field-has-error :deep(.el-input__wrapper) {
background: #fff8f8;
box-shadow: 0 0 0 1px #dc6262 inset !important;
}
@keyframes highlight-pulse {
0% { background-color: #fef0f0; box-shadow: 0 0 0 2px #f56c6c; }
100% { background-color: transparent; box-shadow: none; }
.field-has-warn :deep(.el-input__wrapper) {
background: #fffaf1;
box-shadow: 0 0 0 1px #d79534 inset !important;
}
.field-has-error::after,
.field-has-warn::after {
display: block;
width: 100%;
margin-top: -12px;
font-size: 12px;
line-height: 1.45;
content: attr(data-issue);
}
.field-has-error::after {
color: #b42318;
}
.field-has-warn::after {
color: #9a5b0a;
}
@media (max-width: 767px) {
.review-topbar {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 10px 12px;
}
.topbar-metrics {
justify-content: space-between;
}
.fields-form .fields-grid {
grid-template-columns: 1fr;
}
@ -1287,4 +1137,17 @@ async function handleConfirm() {
:deep(.cell-issue-missing) {
background-color: #fef0f0 !important;
}
:deep(.cell-issue-error) {
color: #b42318;
font-weight: 600;
background-color: #fff0f0 !important;
}
:deep(.cell-issue-warn) {
color: #8a4b00;
font-weight: 600;
background-color: #fff7e8 !important;
}
</style>
min-height: 28px;

View File

@ -45,13 +45,17 @@ export interface StepDef {
icon: Component
}
defineProps<{
withDefaults(defineProps<{
currentStep: number
maxAccessibleStep: number
compact?: boolean
stepErrors?: number[]
stepWarnings?: number[]
}>()
}>(), {
compact: false,
stepErrors: () => [0, 0, 0, 0, 0],
stepWarnings: () => [0, 0, 0, 0, 0],
})
defineEmits<{
'select-step': [step: number]
@ -70,14 +74,14 @@ const steps: StepDef[] = [
.ws-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 8px;
min-width: 180px;
gap: 6px;
padding: 8px 4px;
min-width: 196px;
flex-shrink: 0;
}
.ws-nav.collapsed {
min-width: 52px;
min-width: 58px;
align-items: center;
}
@ -85,43 +89,47 @@ const steps: StepDef[] = [
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: none;
border-radius: 10px;
min-height: 48px;
padding: 9px 12px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
color: #5f7a69;
transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease;
color: #607168;
font-size: 14px;
line-height: 1.4;
position: relative;
}
.ws-nav__item:hover:not(:disabled) {
background: #f0f7f3;
color: #1a2e23;
background: #fff;
border-color: #dfe7e2;
color: #17251e;
}
.ws-nav__item.active {
background: #f0f7f3;
color: #3B7A57;
background: #fff;
border-color: #bdd9ca;
color: #176b4d;
font-weight: 600;
box-shadow: 0 8px 24px rgb(24 61 44 / 7%);
}
.ws-nav__item.active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 0 2px 2px 0;
background: #3B7A57;
border-radius: 3px;
background: #176b4d;
}
.ws-nav__item.completed {
color: #3B7A57;
color: #176b4d;
}
.ws-nav__item.locked {
@ -141,22 +149,22 @@ const steps: StepDef[] = [
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #e8efe9;
width: 30px;
height: 30px;
border-radius: 9px;
background: #e8efeb;
font-size: 15px;
flex-shrink: 0;
}
.ws-nav__item.active .ws-nav__icon {
background: #3B7A57;
background: #176b4d;
color: #fff;
}
.ws-nav__item.completed .ws-nav__icon {
background: #d1fae5;
color: #3B7A57;
background: #dff2e7;
color: #176b4d;
}
.ws-nav__item.has-error .ws-nav__icon {
@ -183,7 +191,7 @@ const steps: StepDef[] = [
.ws-nav__desc {
font-size: 12px;
color: #8b9e93;
color: #84938b;
font-weight: 400;
}
@ -212,7 +220,9 @@ const steps: StepDef[] = [
}
.collapsed .ws-nav__item {
padding: 10px;
width: 48px;
min-height: 48px;
padding: 8px;
justify-content: center;
}