feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
169
web/src/components/dashboard/CfoValueActionDialog.vue
Normal file
169
web/src/components/dashboard/CfoValueActionDialog.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="open"
|
||||
class="cfo-value-action-dialog"
|
||||
width="min(92vw, 560px)"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="actionMeta.label"
|
||||
@update:model-value="$emit('update:open', $event)"
|
||||
>
|
||||
<form class="value-action-form" @submit.prevent="submit">
|
||||
<div class="value-action-context">
|
||||
<span>{{ contextLabel }}</span>
|
||||
<strong>{{ targetTitle }}</strong>
|
||||
<small>当前版本 v{{ Number(target?.version || 0) }}</small>
|
||||
</div>
|
||||
|
||||
<label v-if="action === 'reverse'" class="value-action-field">
|
||||
<span>冲回金额 <em aria-hidden="true">*</em></span>
|
||||
<input
|
||||
v-model="form.reversalAmount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="value-action-field">
|
||||
<span>操作说明 <em aria-hidden="true">*</em></span>
|
||||
<textarea
|
||||
v-model.trim="form.comment"
|
||||
rows="4"
|
||||
maxlength="1000"
|
||||
:placeholder="commentPlaceholder"
|
||||
></textarea>
|
||||
<small>至少 2 个字符;说明将写入不可变事件记录。</small>
|
||||
</label>
|
||||
|
||||
<p v-if="validationError || error" class="value-action-error" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
|
||||
{{ validationError || error?.message || '操作失败,请刷新后重试。' }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="value-action-footer">
|
||||
<button type="button" class="btn ghost" :disabled="submitting" @click="$emit('update:open', false)">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="actionMeta.tone === 'danger' ? 'danger' : 'primary'"
|
||||
:disabled="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
<i v-if="submitting" class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
{{ submitting ? '正在写入证据链' : actionMeta.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { createRequestId, resolveActionMeta } from '../../views/scripts/cfoValueDashboardModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
action: { type: String, default: '' },
|
||||
target: { type: Object, default: () => null },
|
||||
opportunity: { type: Object, default: () => null },
|
||||
submitting: { type: Boolean, default: false },
|
||||
error: { type: Object, default: () => null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'submit'])
|
||||
const form = reactive(createForm())
|
||||
const validationError = ref('')
|
||||
const actionMeta = computed(() => {
|
||||
const meta = resolveActionMeta(props.action)
|
||||
if (props.target?.realizationType || props.target?.realizationKey) {
|
||||
return { ...meta, kind: 'realization' }
|
||||
}
|
||||
return meta
|
||||
})
|
||||
const targetTitle = computed(() => (
|
||||
props.opportunity?.title
|
||||
|| props.target?.title
|
||||
|| props.opportunity?.claimNoSnapshot
|
||||
|| props.target?.id
|
||||
|| '节省机会'
|
||||
))
|
||||
const contextLabel = computed(() => (
|
||||
actionMeta.value.kind === 'realization' ? '实际结果' : '节省机会'
|
||||
))
|
||||
const commentPlaceholder = computed(() => {
|
||||
if (props.action === 'confirm') return '请说明核验依据或外部凭证来源'
|
||||
if (props.action === 'reverse') return '请说明冲回原因及对应事实'
|
||||
if (props.action === 'reject') return '请说明拒绝原因'
|
||||
return '请说明本次操作依据'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.open, props.action, props.target?.id],
|
||||
([open]) => {
|
||||
if (!open) return
|
||||
Object.assign(form, createForm(props.target, props.opportunity))
|
||||
validationError.value = ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function submit() {
|
||||
validationError.value = validate()
|
||||
if (validationError.value || props.submitting) return
|
||||
|
||||
emit('submit', {
|
||||
kind: actionMeta.value.kind,
|
||||
action: props.action,
|
||||
requestId: form.requestId,
|
||||
opportunityId: props.opportunity?.id || props.target?.id,
|
||||
realizationId: actionMeta.value.kind === 'realization' ? props.target?.id : undefined,
|
||||
expectedVersion: Number(props.target?.version || 0),
|
||||
comment: form.comment,
|
||||
...(props.action === 'reverse' ? { reversalAmount: form.reversalAmount } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function validate() {
|
||||
if (String(form.comment || '').trim().length < 2) return '请填写至少 2 个字符的操作说明。'
|
||||
if (Number(props.target?.version || 0) < 1) return '当前记录缺少有效版本,请刷新详情后重试。'
|
||||
if (props.action === 'reverse' && Number(form.reversalAmount) <= 0) return '冲回金额必须大于 0。'
|
||||
return ''
|
||||
}
|
||||
|
||||
function createForm() {
|
||||
return {
|
||||
requestId: createRequestId('cfo-value-action'),
|
||||
comment: '',
|
||||
reversalAmount: ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.value-action-form { display: grid; gap: 18px; }
|
||||
.value-action-context { display: grid; gap: 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
|
||||
.value-action-context span, .value-action-context small { color: var(--muted); font-size: 12px; }
|
||||
.value-action-context strong { color: var(--ink); font-size: 14px; }
|
||||
.value-action-field { display: grid; gap: 7px; color: var(--text); font-size: 13px; font-weight: 700; }
|
||||
.value-action-field em { color: var(--danger); font-style: normal; }
|
||||
.value-action-field input, .value-action-field textarea { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--surface); color: var(--ink); }
|
||||
.value-action-field textarea { resize: vertical; line-height: 1.55; }
|
||||
.value-action-field small { color: var(--muted); font-size: 12px; font-weight: 500; line-height: 1.55; }
|
||||
.value-action-error { display: flex; align-items: flex-start; gap: 8px; color: var(--danger-active); font-size: 13px; line-height: 1.5; }
|
||||
.value-action-footer { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.value-action-footer .btn { min-width: 112px; min-height: 44px; }
|
||||
.btn:disabled { cursor: not-allowed; opacity: .48; transform: none; box-shadow: none; }
|
||||
@media (max-width: 560px) {
|
||||
.value-action-footer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.value-action-footer .btn { width: 100%; min-width: 0; }
|
||||
}
|
||||
</style>
|
||||
393
web/src/components/dashboard/CfoValueDashboard.vue
Normal file
393
web/src/components/dashboard/CfoValueDashboard.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<section class="cfo-value-dashboard" aria-labelledby="cfo-value-title">
|
||||
<header class="panel value-dashboard-header">
|
||||
<div>
|
||||
<span class="value-eyebrow">Evidence-backed value</span>
|
||||
<h2 id="cfo-value-title">经营价值与节省闭环</h2>
|
||||
<p>只把有基线、证据、canonical 去重和独立财务确认的实际结果计入现金节省。</p>
|
||||
</div>
|
||||
<div class="value-source-summary">
|
||||
<span :class="['value-source-status', dashboardState]">
|
||||
<i class="mdi mdi-database-check-outline" aria-hidden="true"></i>
|
||||
{{ sourceStatusLabel }}
|
||||
</span>
|
||||
<small>{{ windowLabel }}</small>
|
||||
<small>{{ freshnessLabel }}</small>
|
||||
<button type="button" class="btn" :disabled="dashboardLoading" @click="reloadAll">
|
||||
<i :class="dashboardLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'" aria-hidden="true"></i>
|
||||
{{ dashboardLoading ? '同步中' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel value-filter-panel" aria-labelledby="value-filter-title">
|
||||
<div class="value-filter-head">
|
||||
<div>
|
||||
<h3 id="value-filter-title">筛选经营价值</h3>
|
||||
<p>筛选条件会写入当前 URL,刷新和返回后仍可恢复。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="value-filter-toggle"
|
||||
:aria-expanded="advancedFiltersOpen ? 'true' : 'false'"
|
||||
aria-controls="value-advanced-filters"
|
||||
@click="advancedFiltersOpen = !advancedFiltersOpen"
|
||||
>
|
||||
<i class="mdi mdi-tune-variant" aria-hidden="true"></i>
|
||||
高级筛选
|
||||
<span v-if="activeFilterCount">{{ activeFilterCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="value-filter-form" @submit.prevent="applyFilters">
|
||||
<div class="value-filter-primary">
|
||||
<label>
|
||||
<span>价值类型</span>
|
||||
<select v-model="filterDraft.valueKind">
|
||||
<option value="">全部类型</option>
|
||||
<option value="cash">现金节省</option>
|
||||
<option value="labor">工时价值</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>机会状态 <em>仅筛选下方台账</em></span>
|
||||
<select v-model="filterDraft.status">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="option in statusOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>来源类型</span>
|
||||
<input v-model.trim="filterDraft.sourceType" maxlength="50" placeholder="例如 standard_adjustment" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>负责人 ID</span>
|
||||
<input v-model.trim="filterDraft.ownerId" maxlength="120" placeholder="输入负责人 ID" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="advancedFiltersOpen" id="value-advanced-filters" class="value-filter-advanced">
|
||||
<label><span>部门 ID</span><input v-model.trim="filterDraft.departmentId" maxlength="160" autocomplete="off" /></label>
|
||||
<label><span>项目编码</span><input v-model.trim="filterDraft.projectCode" maxlength="160" autocomplete="off" /></label>
|
||||
<label><span>费用类型</span><input v-model.trim="filterDraft.expenseType" maxlength="80" autocomplete="off" /></label>
|
||||
<label><span>供应商 ID</span><input v-model.trim="filterDraft.supplierId" maxlength="160" autocomplete="off" /></label>
|
||||
<label><span>城市</span><input v-model.trim="filterDraft.city" maxlength="160" autocomplete="off" /></label>
|
||||
</div>
|
||||
<div class="value-filter-actions">
|
||||
<button type="button" class="btn ghost" :disabled="dashboardLoading" @click="resetFilters">重置</button>
|
||||
<button type="submit" class="btn primary" :disabled="dashboardLoading">
|
||||
<i class="mdi mdi-filter-check-outline" aria-hidden="true"></i>
|
||||
应用筛选
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div v-if="mutationMessage" class="value-feedback success" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
|
||||
{{ mutationMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardState === 'loading'" class="panel value-page-state loading" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
<strong>正在核对价值台账与财务确认记录</strong>
|
||||
<span>加载期间不会展示旧的演示数字。</span>
|
||||
</div>
|
||||
<div v-else-if="dashboardState === 'permission'" class="panel value-page-state permission" role="alert">
|
||||
<i class="mdi mdi-shield-lock-outline" aria-hidden="true"></i>
|
||||
<strong>当前账号无权查看企业经营价值</strong>
|
||||
<span>{{ dashboardError?.message || '该看板仅向财务、管理层和授权平台管理员开放。' }}</span>
|
||||
</div>
|
||||
<div v-else-if="dashboardState === 'error'" class="panel value-page-state error" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
|
||||
<strong>经营价值数据加载失败</strong>
|
||||
<span>{{ dashboardError?.message || '请检查网络后重试。' }}</span>
|
||||
<button type="button" class="btn" @click="reloadAll">重新加载</button>
|
||||
</div>
|
||||
<div v-else-if="dashboardState === 'empty'" class="panel value-page-state empty" role="status">
|
||||
<i class="mdi mdi-database-off-outline" aria-hidden="true"></i>
|
||||
<strong>当前范围还没有价值台账记录</strong>
|
||||
<span>这不是零节省;系统尚无可用于判断的机会或实际结果。可调整顶部时间范围或清空筛选。</span>
|
||||
<button v-if="activeFilterCount" type="button" class="btn" @click="resetFilters">清空筛选</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="dashboardState === 'partial'" class="value-feedback warning" role="status">
|
||||
<i class="mdi mdi-alert-outline" aria-hidden="true"></i>
|
||||
当前结果为部分覆盖:存在证据、汇率或去重待补项,相关记录不会进入已核验主指标。
|
||||
</div>
|
||||
<div v-if="dashboardStale" class="value-feedback stale" role="status">
|
||||
<i class="mdi mdi-clock-alert-outline" aria-hidden="true"></i>
|
||||
数据源超过 24 小时未产生新事实;页面保留最后一次真实结果并明确标记为陈旧。
|
||||
</div>
|
||||
<div v-if="dashboardError" class="value-feedback error" role="alert">
|
||||
<i class="mdi mdi-cloud-alert-outline" aria-hidden="true"></i>
|
||||
最新同步失败,当前展示的是上一次成功读取的数据:{{ dashboardError.message }}
|
||||
</div>
|
||||
|
||||
<div class="value-kpi-grid">
|
||||
<article v-for="kpi in kpis" :key="kpi.key" :class="['panel', 'value-kpi-card', kpi.state]">
|
||||
<div class="value-kpi-head">
|
||||
<span>{{ kpi.label }}</span>
|
||||
<em>{{ kpi.statusLabel }}</em>
|
||||
</div>
|
||||
<strong>{{ kpi.displayValue }}</strong>
|
||||
<p>{{ kpi.detail }}</p>
|
||||
<details v-if="kpi.requiredInputs?.length || kpi.definition">
|
||||
<summary>{{ kpi.requiredInputs?.length ? '查看缺失输入' : '查看口径' }}</summary>
|
||||
<ul v-if="kpi.requiredInputs?.length">
|
||||
<li v-for="item in kpi.requiredInputs" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<p v-else>{{ kpi.definition }}</p>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="value-dashboard-grid">
|
||||
<article class="panel value-dashboard-card value-funnel-card">
|
||||
<div class="value-card-head">
|
||||
<div><h3>价值转化漏斗</h3><p>预计、执行、实际、确认与冲回严格分层。</p></div>
|
||||
</div>
|
||||
<ol class="value-funnel-list">
|
||||
<li v-for="stage in funnelRows" :key="stage.key">
|
||||
<div><span>{{ stage.label }}</span><strong>{{ stage.count }} 项</strong></div>
|
||||
<i aria-hidden="true"><b :style="{ width: stage.width }"></b></i>
|
||||
<small>{{ stage.valueLabel }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
<div v-if="realizationRates.length" class="value-realization-rates">
|
||||
<span v-for="item in realizationRates" :key="item.currency">
|
||||
{{ item.currency }} 成熟机会实现率 <strong>{{ item.label }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel value-dashboard-card value-trend-card">
|
||||
<div class="value-card-head">
|
||||
<div><h3>实际价值趋势</h3><p>按实际发生月份和币种展示,不跨币种相加。</p></div>
|
||||
<label v-if="trend.currencies.length > 1" class="value-compact-select">
|
||||
<span>币种</span>
|
||||
<select v-model="trendCurrency">
|
||||
<option v-for="currency in trend.currencies" :key="currency" :value="currency">{{ currency }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<CfoValueTrendChart :rows="trend.rows" :currency="trend.selectedCurrency" />
|
||||
</article>
|
||||
|
||||
<article class="panel value-dashboard-card value-breakdown-card">
|
||||
<div class="value-card-head">
|
||||
<div><h3>价值归因分解</h3><p>按业务维度钻取预计价值与财务确认价值。</p></div>
|
||||
</div>
|
||||
<div class="value-dimension-tabs" role="group" aria-label="价值分解维度">
|
||||
<button
|
||||
v-for="group in breakdownGroups"
|
||||
:key="group.dimension"
|
||||
type="button"
|
||||
:aria-pressed="activeBreakdown.dimension === group.dimension ? 'true' : 'false'"
|
||||
:class="{ active: activeBreakdown.dimension === group.dimension }"
|
||||
@click="activeBreakdownDimension = group.dimension"
|
||||
>{{ group.label }}</button>
|
||||
</div>
|
||||
<div v-if="activeBreakdown.items.length" class="value-breakdown-list">
|
||||
<div v-for="item in activeBreakdown.items" :key="item.rowKey" class="value-breakdown-row">
|
||||
<div><strong>{{ item.dimensionName }}</strong><span>{{ item.opportunityCount }} 个机会 · {{ item.currency || '无币种' }}</span></div>
|
||||
<i aria-hidden="true"><b :style="{ width: item.width }"></b></i>
|
||||
<div><strong>{{ item.verifiedLabel }}</strong><span>预计 {{ item.estimatedLabel }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="value-inline-state">该维度暂无可归因记录。</p>
|
||||
</article>
|
||||
|
||||
<article class="panel value-dashboard-card value-guardrail-card">
|
||||
<div class="value-card-head">
|
||||
<div><h3>价值护栏</h3><p>避免误把风险暴露、重复记录或未确认结果算成节省。</p></div>
|
||||
</div>
|
||||
<div class="value-guardrail-list">
|
||||
<div v-for="item in guardrailRows" :key="item.key" :class="['value-guardrail-row', item.status]">
|
||||
<i :class="item.status === 'ok' ? 'mdi mdi-check-circle-outline' : (item.status === 'attention' ? 'mdi mdi-alert-outline' : 'mdi mdi-minus-circle-outline')" aria-hidden="true"></i>
|
||||
<div><strong>{{ item.label }}</strong><span>{{ item.reason || [item.countLabel, item.valueLabel, item.rateLabel].filter((value) => value && value !== '—').join(' · ') }}</span></div>
|
||||
<em>{{ item.statusLabel }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel value-dashboard-card value-quality-card">
|
||||
<div class="value-card-head">
|
||||
<div><h3>数据质量与覆盖</h3><p>每个缺口都明确展示,不使用默认值掩盖。</p></div>
|
||||
<span class="value-source-chip">Savings Ledger {{ dashboard?.source?.ledgerRevision || '' }}</span>
|
||||
</div>
|
||||
<div class="value-quality-grid">
|
||||
<div v-for="item in dataQualityRows" :key="item.key" :class="item.tone">
|
||||
<strong>{{ item.displayValue }}</strong><span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="qualityNotes.length || coverageNotes.length" class="value-coverage-notes">
|
||||
<li v-for="note in [...qualityNotes, ...coverageNotes]" :key="note">{{ note }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel value-opportunity-panel" aria-labelledby="value-opportunities-title">
|
||||
<div class="value-card-head">
|
||||
<div>
|
||||
<h3 id="value-opportunities-title">节省机会台账</h3>
|
||||
<p>共 {{ Number(opportunities.total || 0) }} 条;进入详情可查看基线、证据、实际结果和不可变事件。</p>
|
||||
</div>
|
||||
<span v-if="opportunitiesLoading" class="value-list-loading" role="status"><i class="mdi mdi-loading mdi-spin"></i>同步中</span>
|
||||
</div>
|
||||
<div v-if="opportunitiesLoading && !opportunities.generatedAt" class="value-list-state" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
<span>正在加载节省机会台账</span>
|
||||
</div>
|
||||
<div v-else-if="opportunitiesError" class="value-list-state error" role="alert">
|
||||
<span>{{ opportunitiesError.message || '机会台账加载失败。' }}</span>
|
||||
<button type="button" class="btn" @click="loadOpportunities">重试</button>
|
||||
</div>
|
||||
<div v-else-if="opportunityRows.length" class="value-opportunity-list">
|
||||
<article v-for="item in opportunityRows" :key="item.id" class="value-opportunity-row">
|
||||
<div class="value-opportunity-main">
|
||||
<span :class="['value-status-pill', item.statusMeta.tone]">{{ item.statusMeta.label }}</span>
|
||||
<div><strong>{{ item.title }}</strong><small>{{ item.claimNoSnapshot }} · {{ item.category }} · {{ item.sourceType }}</small></div>
|
||||
</div>
|
||||
<div class="value-opportunity-amount"><strong>{{ item.estimatedLabel }}</strong><span>预计净节省 · 置信度 {{ item.confidenceLabel }}</span></div>
|
||||
<div class="value-opportunity-owner"><strong>{{ item.ownerLabel }}</strong><span>更新 {{ item.updatedLabel }}</span></div>
|
||||
<button type="button" class="btn" @click="openOpportunity(item.id)">查看证据链</button>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="value-list-state" role="status">当前筛选条件下暂无节省机会。</div>
|
||||
<el-pagination
|
||||
v-if="Number(opportunities.totalPages || 0) > 1"
|
||||
class="value-pagination"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="page"
|
||||
:page-count="Number(opportunities.totalPages || 0)"
|
||||
@current-change="setPage"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<CfoValueOpportunityDrawer
|
||||
:open="detailOpen"
|
||||
:opportunity="selectedOpportunity"
|
||||
:loading="selectedOpportunityLoading"
|
||||
:error="selectedOpportunityError"
|
||||
@update:open="handleDetailOpenChange"
|
||||
@retry="openOpportunity(selectedOpportunityId)"
|
||||
@action="openActionDialog"
|
||||
/>
|
||||
<CfoValueActionDialog
|
||||
v-model:open="actionDialogOpen"
|
||||
:action="actionContext.action"
|
||||
:target="actionContext.target"
|
||||
:opportunity="actionContext.opportunity"
|
||||
:submitting="mutationLoading"
|
||||
:error="mutationError"
|
||||
@submit="handleSubmitAction"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import CfoValueTrendChart from '../charts/CfoValueTrendChart.vue'
|
||||
import CfoValueActionDialog from './CfoValueActionDialog.vue'
|
||||
import CfoValueOpportunityDrawer from './CfoValueOpportunityDrawer.vue'
|
||||
import { useCfoValueDashboard } from '../../composables/useCfoValueDashboard.js'
|
||||
import { formatDateTime, formatPercent } from '../../views/scripts/cfoValueDashboardModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
activeRange: { type: String, default: '近10日' },
|
||||
customRange: { type: Object, default: () => ({ start: '', end: '' }) }
|
||||
})
|
||||
|
||||
const advancedFiltersOpen = ref(false)
|
||||
const actionDialogOpen = ref(false)
|
||||
const actionContext = reactive({ action: '', target: null, opportunity: null })
|
||||
const statusOptions = [
|
||||
['identified', '待评估'], ['accepted', '已接受'], ['in_progress', '执行中'],
|
||||
['realized', '实际待确认'], ['verified', '财务已确认'], ['reversed', '已冲回'],
|
||||
['rejected', '已拒绝'], ['expired', '已到期']
|
||||
].map(([value, label]) => ({ value, label }))
|
||||
|
||||
const {
|
||||
activeBreakdown,
|
||||
activeBreakdownDimension,
|
||||
breakdownGroups,
|
||||
dashboard,
|
||||
dashboardError,
|
||||
dashboardLoading,
|
||||
dashboardState,
|
||||
dashboardStale,
|
||||
dataQualityRows,
|
||||
detailOpen,
|
||||
filterDraft,
|
||||
funnelRows,
|
||||
guardrailRows,
|
||||
kpis,
|
||||
mutationError,
|
||||
mutationLoading,
|
||||
mutationMessage,
|
||||
opportunities,
|
||||
opportunitiesError,
|
||||
opportunitiesLoading,
|
||||
opportunityRows,
|
||||
page,
|
||||
selectedOpportunity,
|
||||
selectedOpportunityError,
|
||||
selectedOpportunityId,
|
||||
selectedOpportunityLoading,
|
||||
trend,
|
||||
trendCurrency,
|
||||
applyFilters,
|
||||
closeOpportunity,
|
||||
loadOpportunities,
|
||||
openOpportunity,
|
||||
reloadAll,
|
||||
resetFilters,
|
||||
setPage,
|
||||
submitAction
|
||||
} = useCfoValueDashboard(props)
|
||||
|
||||
const activeFilterCount = computed(() => Object.values(filterDraft).filter((value) => String(value || '').trim()).length)
|
||||
const coverageNotes = computed(() => dashboard.value?.source?.coverageNotes || [])
|
||||
const qualityNotes = computed(() => dashboard.value?.dataQuality?.notes || [])
|
||||
const windowLabel = computed(() => {
|
||||
const window = dashboard.value?.window
|
||||
if (!window) return `时间范围:${props.activeRange}`
|
||||
return `${new Date(window.start).toLocaleDateString('zh-CN')} - ${new Date(window.end).toLocaleDateString('zh-CN')}`
|
||||
})
|
||||
const freshnessLabel = computed(() => {
|
||||
const freshness = dashboard.value?.source?.freshnessAt
|
||||
return freshness ? `事实更新:${formatDateTime(freshness)}` : '事实更新:暂无记录'
|
||||
})
|
||||
const sourceStatusLabel = computed(() => ({
|
||||
loading: '正在核对', permission: '无访问权限', error: '读取失败', empty: '暂无事实',
|
||||
partial: '部分覆盖', stale: '数据陈旧', ready: '真实数据'
|
||||
}[dashboardState.value] || '状态未知'))
|
||||
const realizationRates = computed(() => Object.entries(dashboard.value?.funnel?.realizationRateByCurrency || {})
|
||||
.map(([currency, value]) => ({ currency, label: value === null ? '不可计算' : formatPercent(value) })))
|
||||
|
||||
function handleDetailOpenChange(open) {
|
||||
if (!open) closeOpportunity()
|
||||
}
|
||||
|
||||
function openActionDialog(payload) {
|
||||
actionContext.action = payload.action
|
||||
actionContext.target = payload.target
|
||||
actionContext.opportunity = payload.opportunity
|
||||
mutationError.value = null
|
||||
actionDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleSubmitAction(command) {
|
||||
try {
|
||||
await submitAction(command)
|
||||
actionDialogOpen.value = false
|
||||
} catch {
|
||||
// 错误由对话框内的可访问错误区域展示,保留表单便于修正或重试。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/cfo-value-dashboard.css"></style>
|
||||
330
web/src/components/dashboard/CfoValueOpportunityDrawer.vue
Normal file
330
web/src/components/dashboard/CfoValueOpportunityDrawer.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="open"
|
||||
class="cfo-value-opportunity-drawer"
|
||||
size="min(96vw, 760px)"
|
||||
:with-header="false"
|
||||
@update:model-value="$emit('update:open', $event)"
|
||||
>
|
||||
<div class="value-drawer-shell">
|
||||
<header class="value-drawer-head">
|
||||
<div>
|
||||
<span class="value-drawer-eyebrow">节省机会证据链</span>
|
||||
<h2>{{ opportunity?.title || '节省机会详情' }}</h2>
|
||||
</div>
|
||||
<button type="button" class="value-icon-button" aria-label="关闭节省机会详情" @click="$emit('update:open', false)">
|
||||
<i class="mdi mdi-close" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="value-drawer-state" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
<strong>正在加载基线、证据和事件记录</strong>
|
||||
</div>
|
||||
<div v-else-if="error" class="value-drawer-state error" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
|
||||
<strong>{{ error.message || '节省机会详情加载失败' }}</strong>
|
||||
<button type="button" class="btn" @click="$emit('retry')">重试</button>
|
||||
</div>
|
||||
<div v-else-if="detail" class="value-drawer-content">
|
||||
<section class="value-detail-hero" aria-labelledby="value-detail-summary-title">
|
||||
<div>
|
||||
<span :class="['value-status-pill', detail.statusMeta.tone]">{{ detail.statusMeta.label }}</span>
|
||||
<span class="value-version-pill">v{{ detail.version }}</span>
|
||||
</div>
|
||||
<h3 id="value-detail-summary-title">{{ detail.estimatedLabel }}</h3>
|
||||
<p>{{ detail.description || '暂无机会说明。' }}</p>
|
||||
<dl>
|
||||
<div><dt>单据</dt><dd>{{ detail.claimNoSnapshot || detail.claimId }}</dd></div>
|
||||
<div><dt>负责人</dt><dd>{{ detail.ownerLabel }}</dd></div>
|
||||
<div><dt>可信度</dt><dd>{{ detail.confidenceLabel }}</dd></div>
|
||||
<div><dt>目标日期</dt><dd>{{ detail.dueLabel }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="value-detail-section" aria-labelledby="value-source-links-title">
|
||||
<div class="value-section-head">
|
||||
<h3 id="value-source-links-title">查看来源</h3>
|
||||
<span>{{ sourceLinks.length }} 个入口</span>
|
||||
</div>
|
||||
<nav v-if="sourceLinks.length" class="value-source-link-list" aria-label="节省机会来源入口">
|
||||
<RouterLink
|
||||
v-for="link in sourceLinks"
|
||||
:key="link.key"
|
||||
:to="link.to"
|
||||
:class="['value-source-link', link.kind]"
|
||||
>
|
||||
<i :class="link.icon" aria-hidden="true"></i>
|
||||
<span>
|
||||
<strong>{{ link.label }}</strong>
|
||||
<small>{{ link.description }}</small>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<p v-else class="value-inline-empty">当前机会没有可验证的关联单据、预算维度或分析维度入口。</p>
|
||||
</section>
|
||||
|
||||
<section class="value-detail-section" aria-labelledby="value-actions-title">
|
||||
<div class="value-section-head">
|
||||
<h3 id="value-actions-title">可执行动作</h3>
|
||||
<span v-if="detail.readOnlyReason">只读</span>
|
||||
</div>
|
||||
<div v-if="opportunityActions.length" class="value-action-list">
|
||||
<button
|
||||
v-for="action in opportunityActions"
|
||||
:key="action"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="resolveActionMeta(action).tone === 'danger' ? 'danger' : 'primary'"
|
||||
@click="$emit('action', { action, target: opportunity, opportunity })"
|
||||
>
|
||||
{{ resolveActionMeta(action).label }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="!requiresExternalEvidence" class="value-inline-empty">
|
||||
{{ detail.readOnlyReason || '当前状态没有可执行动作。' }}
|
||||
</p>
|
||||
<p v-if="requiresExternalEvidence" class="value-inline-empty evidence-required" role="status">
|
||||
实际结果必须来自平台付款事件,或关联可追溯的支付、银行、ERP 凭证。当前页面不会提交空证据;
|
||||
外部凭证入口接入前,请先完成付款流程或等待连接器回执。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="value-detail-section" aria-labelledby="value-baseline-title">
|
||||
<div class="value-section-head">
|
||||
<h3 id="value-baseline-title">冻结基线</h3>
|
||||
<span v-if="opportunity.baseline">{{ opportunity.baseline.dataQualityStatus }}</span>
|
||||
</div>
|
||||
<dl v-if="opportunity.baseline" class="value-detail-grid">
|
||||
<div><dt>基线类型</dt><dd>{{ opportunity.baseline.baselineType }}</dd></div>
|
||||
<div><dt>基线值</dt><dd>{{ formatBaselineValue(opportunity.baseline) }}</dd></div>
|
||||
<div><dt>样本量</dt><dd>{{ opportunity.baseline.sampleCount }}</dd></div>
|
||||
<div><dt>质量评分</dt><dd>{{ opportunity.baseline.dataQualityScore }}</dd></div>
|
||||
<div><dt>算法版本</dt><dd>{{ opportunity.baseline.algorithmVersion }}</dd></div>
|
||||
<div><dt>政策版本</dt><dd>{{ opportunity.baseline.policyVersion || '未绑定' }}</dd></div>
|
||||
</dl>
|
||||
<p v-else class="value-inline-empty baseline-missing">基线详情不可用,当前机会不能作为已核验价值依据。</p>
|
||||
</section>
|
||||
|
||||
<section class="value-detail-section" aria-labelledby="value-realizations-title">
|
||||
<div class="value-section-head">
|
||||
<h3 id="value-realizations-title">实际结果</h3>
|
||||
<span>{{ realizations.length }} 笔</span>
|
||||
</div>
|
||||
<div v-if="realizations.length" class="value-realization-list">
|
||||
<article v-for="realization in realizations" :key="realization.id" class="value-realization-card">
|
||||
<div class="value-realization-head">
|
||||
<div>
|
||||
<strong>{{ formatMoney(realization.reportingAmount, realization.reportingCurrency) }}</strong>
|
||||
<span>{{ realization.realizationType === 'reversal' ? '冲回记录' : '实际结果' }} · {{ realizationStatus(realization.status) }}</span>
|
||||
</div>
|
||||
<span>v{{ realization.version }}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>发生时间</dt><dd>{{ formatDateTime(realization.realizedAt) }}</dd></div>
|
||||
<div><dt>记录人</dt><dd>{{ realization.recordedByName || realization.recordedById }}</dd></div>
|
||||
<div><dt>去重状态</dt><dd>{{ realization.dedupeStatus }}</dd></div>
|
||||
<div><dt>汇率证据</dt><dd>{{ realization.fxSource }} / {{ realization.fxVersion }}</dd></div>
|
||||
<div><dt>财务确认</dt><dd>{{ financeConfirmationLabel(realization) }}</dd></div>
|
||||
<div><dt>确认时间</dt><dd>{{ formatDateTime(realization.confirmedAt) }}</dd></div>
|
||||
</dl>
|
||||
<p v-if="realization.confirmationNote" class="value-record-note">
|
||||
确认说明:{{ realization.confirmationNote }}
|
||||
</p>
|
||||
<p v-else-if="realization.rejectionReason" class="value-record-note">
|
||||
拒绝原因:{{ realization.rejectionReason }}
|
||||
</p>
|
||||
<p v-else-if="realization.reversalReason" class="value-record-note">
|
||||
冲回原因:{{ realization.reversalReason }}
|
||||
</p>
|
||||
<div v-if="realization.availableActions?.length" class="value-action-list compact">
|
||||
<button
|
||||
v-for="action in realization.availableActions"
|
||||
:key="action"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="resolveActionMeta(action).tone === 'danger' ? 'danger' : ''"
|
||||
@click="$emit('action', { action, target: realization, opportunity })"
|
||||
>
|
||||
{{ resolveActionMeta(action).label }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="realization.readOnlyReason" class="value-record-note">{{ realization.readOnlyReason }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="value-inline-empty">尚未记录实际结果;预计金额不会进入 CFO 主指标。</p>
|
||||
</section>
|
||||
|
||||
<section class="value-detail-section" aria-labelledby="value-evidence-title">
|
||||
<div class="value-section-head">
|
||||
<h3 id="value-evidence-title">证据索引</h3>
|
||||
<span>{{ evidence.length }} 条</span>
|
||||
</div>
|
||||
<ol v-if="evidence.length" class="value-evidence-list">
|
||||
<li v-for="item in evidence" :key="item.id || item.evidenceKey">
|
||||
<i class="mdi mdi-file-certificate-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>{{ item.evidenceRole || item.resourceType }}</strong>
|
||||
<span>{{ item.sourceSystem }} · {{ item.resourceId }}</span>
|
||||
<small>{{ item.verificationStatus }} · {{ formatDateTime(item.occurredAt) }}</small>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="value-inline-empty">暂无独立证据索引,请补齐后再确认价值。</p>
|
||||
</section>
|
||||
|
||||
<section class="value-detail-section" aria-labelledby="value-events-title">
|
||||
<div class="value-section-head">
|
||||
<h3 id="value-events-title">不可变事件记录</h3>
|
||||
<span>{{ events.length }} 条</span>
|
||||
</div>
|
||||
<ol v-if="events.length" class="value-event-list">
|
||||
<li v-for="event in events" :key="event.id">
|
||||
<i aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>{{ event.action }}</strong>
|
||||
<span>{{ event.actorName || event.actorId }} · v{{ event.expectedVersion }} → v{{ event.resultVersion }}</span>
|
||||
<small>{{ formatDateTime(event.occurredAt) }} · 请求 {{ event.requestId }}</small>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="value-inline-empty">暂无事件记录。</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
buildOpportunityRows,
|
||||
formatDateTime,
|
||||
formatMoney,
|
||||
resolveActionMeta
|
||||
} from '../../views/scripts/cfoValueDashboardModel.js'
|
||||
import { buildCfoOpportunitySourceLinks } from '../../views/scripts/cfoValueSourceLinks.js'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
opportunity: { type: Object, default: () => null },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: Object, default: () => null }
|
||||
})
|
||||
|
||||
defineEmits(['update:open', 'retry', 'action'])
|
||||
|
||||
const route = useRoute()
|
||||
const detail = computed(() => (
|
||||
props.opportunity ? buildOpportunityRows({ items: [props.opportunity] })[0] : null
|
||||
))
|
||||
const sourceLinks = computed(() => buildCfoOpportunitySourceLinks(props.opportunity || {}, {
|
||||
dashboardQuery: route.query
|
||||
}))
|
||||
const realizations = computed(() => props.opportunity?.realizations || [])
|
||||
const evidence = computed(() => props.opportunity?.evidence || [])
|
||||
const events = computed(() => props.opportunity?.events || [])
|
||||
const opportunityActions = computed(() => (
|
||||
detail.value?.availableActions?.filter((action) => action !== 'record_realization') || []
|
||||
))
|
||||
const requiresExternalEvidence = computed(() => (
|
||||
detail.value?.availableActions?.includes('record_realization') || false
|
||||
))
|
||||
|
||||
function realizationStatus(status) {
|
||||
return {
|
||||
pending_confirmation: '待财务确认',
|
||||
finance_confirmed: '财务已确认',
|
||||
rejected: '已拒绝',
|
||||
reversed: '已冲回'
|
||||
}[status] || status || '未知'
|
||||
}
|
||||
|
||||
function financeConfirmationLabel(realization = {}) {
|
||||
if (realization.status === 'finance_confirmed') {
|
||||
return realization.financeConfirmerName || realization.financeConfirmerId || '独立财务已确认'
|
||||
}
|
||||
if (realization.status === 'rejected') {
|
||||
return realization.rejectedByName || realization.rejectedById || '财务已拒绝'
|
||||
}
|
||||
if (realization.status === 'reversed') {
|
||||
return realization.reversedByName || realization.reversedById || '已追加冲回'
|
||||
}
|
||||
return '待独立财务确认'
|
||||
}
|
||||
|
||||
function formatBaselineValue(baseline = {}) {
|
||||
if (baseline.originalCurrency) {
|
||||
return formatMoney(baseline.baselineValue, baseline.originalCurrency)
|
||||
}
|
||||
const unit = String(baseline.unit || '').trim()
|
||||
return `${Number(baseline.baselineValue || 0).toLocaleString('zh-CN', { maximumFractionDigits: 4 })}${unit ? ` ${unit}` : ''}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.value-drawer-shell { min-height: 100%; display: flex; flex-direction: column; background: var(--bg); }
|
||||
.value-drawer-head { position: sticky; top: 0; z-index: 2; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 22px; border-bottom: 1px solid var(--line); background: rgba(255, 255, 255, .96); backdrop-filter: blur(12px); }
|
||||
.value-drawer-eyebrow { display: block; color: var(--theme-primary-active); font-size: 12px; font-weight: 800; letter-spacing: .08em; }
|
||||
.value-drawer-head h2 { margin: 5px 0 0; color: var(--ink); font-size: 20px; line-height: 1.35; }
|
||||
.value-icon-button { width: 44px; height: 44px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); font-size: 20px; }
|
||||
.value-drawer-state { min-height: 360px; display: grid; place-content: center; justify-items: center; gap: 14px; padding: 24px; color: var(--muted); text-align: center; }
|
||||
.value-drawer-state > i { color: var(--theme-primary); font-size: 32px; }
|
||||
.value-drawer-state.error > i { color: var(--danger); }
|
||||
.value-drawer-state .btn { min-height: 44px; }
|
||||
.value-drawer-content { display: grid; gap: 14px; padding: 16px 22px 32px; }
|
||||
.value-detail-hero, .value-detail-section { padding: 18px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: 0 1px 2px rgba(15, 23, 42, .04); }
|
||||
.value-detail-hero > div:first-child { display: flex; gap: 8px; }
|
||||
.value-status-pill, .value-version-pill, .value-section-head > span { min-height: 24px; display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; background: var(--info-soft); color: var(--info); font-size: 12px; font-weight: 800; }
|
||||
.value-status-pill.success { background: var(--success-soft); color: var(--success); }
|
||||
.value-status-pill.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.value-status-pill.danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.value-status-pill.primary { background: var(--theme-primary-soft); color: var(--theme-primary-active); }
|
||||
.value-detail-hero h3 { margin: 16px 0 8px; color: var(--ink); font-size: 30px; font-variant-numeric: tabular-nums; }
|
||||
.value-detail-hero > p { color: var(--muted); font-size: 13px; line-height: 1.65; }
|
||||
.value-detail-hero dl, .value-detail-grid, .value-realization-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 18px 0 0; }
|
||||
dl div { min-width: 0; }
|
||||
dt { color: var(--muted); font-size: 12px; }
|
||||
dd { margin: 4px 0 0; color: var(--ink); font-size: 13px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.value-section-head, .value-realization-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.value-section-head { margin-bottom: 14px; }
|
||||
.value-section-head h3 { margin: 0; color: var(--ink); font-size: 15px; }
|
||||
.value-action-list { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.value-action-list .btn { min-height: 44px; }
|
||||
.value-action-list.compact { margin-top: 14px; }
|
||||
.value-action-list.compact .btn { min-height: 44px; font-size: 12px; }
|
||||
.value-source-link-list { display: grid; gap: 10px; }
|
||||
.value-source-link { min-height: 64px; display: grid; grid-template-columns: 36px minmax(0, 1fr) 18px; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); color: var(--text); text-decoration: none; transition: border-color 160ms ease, background 160ms ease; }
|
||||
.value-source-link:hover, .value-source-link:focus-visible { border-color: var(--theme-primary); background: var(--theme-primary-soft); outline: none; }
|
||||
.value-source-link > i:first-child { width: 36px; height: 36px; display: grid; place-items: center; border-radius: var(--radius); background: var(--surface); color: var(--theme-primary-active); font-size: 18px; }
|
||||
.value-source-link > span { min-width: 0; }
|
||||
.value-source-link strong, .value-source-link small { display: block; }
|
||||
.value-source-link strong { color: var(--ink); font-size: 13px; }
|
||||
.value-source-link small { margin-top: 4px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.value-source-link > i:last-child { color: var(--muted); }
|
||||
.value-source-link.budget small { color: var(--warning-active); }
|
||||
.value-inline-empty, .value-record-note { padding: 12px; border-radius: var(--radius); background: var(--surface-soft); color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
.value-inline-empty.baseline-missing { border-left: 3px solid var(--warning); background: var(--warning-soft); color: var(--warning-active); }
|
||||
.value-inline-empty.evidence-required { border-left: 3px solid var(--warning); background: var(--warning-soft); color: var(--warning-active); }
|
||||
.value-realization-list { display: grid; gap: 12px; }
|
||||
.value-realization-card { padding: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
|
||||
.value-realization-head strong { display: block; color: var(--ink); font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.value-realization-head span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; }
|
||||
.value-evidence-list, .value-event-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.value-evidence-list li, .value-event-list li { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
|
||||
.value-evidence-list > li > i { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: var(--radius); background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 17px; }
|
||||
.value-evidence-list div, .value-event-list div { min-width: 0; }
|
||||
.value-evidence-list strong, .value-event-list strong { display: block; color: var(--ink); font-size: 13px; }
|
||||
.value-evidence-list span, .value-evidence-list small, .value-event-list span, .value-event-list small { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.value-event-list > li > i { width: 10px; height: 10px; margin: 5px 4px 0; flex: 0 0 auto; border: 3px solid var(--theme-primary-soft-strong); border-radius: 50%; background: var(--theme-primary); box-sizing: content-box; }
|
||||
@media (max-width: 560px) {
|
||||
.value-drawer-head, .value-drawer-content { padding-left: 14px; padding-right: 14px; }
|
||||
.value-detail-hero h3 { font-size: 24px; }
|
||||
.value-detail-hero dl, .value-detail-grid, .value-realization-card dl { grid-template-columns: 1fr; }
|
||||
.value-action-list { display: grid; grid-template-columns: 1fr; }
|
||||
.value-action-list .btn { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
237
web/src/components/dashboard/FinancialConnectorHealthPanel.vue
Normal file
237
web/src/components/dashboard/FinancialConnectorHealthPanel.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<section v-if="canView" class="connector-health panel" aria-label="财务连接器运行观测">
|
||||
<header class="connector-health__head">
|
||||
<div>
|
||||
<span class="connector-health__eyebrow">FINANCIAL CONNECTOR</span>
|
||||
<h3>外部回执与对账健康度</h3>
|
||||
<p>只展示脱敏运行事实;模拟回执和人工付款不会冒充生产现金证明。</p>
|
||||
</div>
|
||||
<button type="button" :disabled="loading" @click="load">
|
||||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
|
||||
{{ loading ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="connector-health__state is-error" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<div v-else-if="loading && !payload" class="connector-health__state" role="status">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在读取当前租户连接器指标</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="connector-health__metrics">
|
||||
<article v-for="metric in metrics" :key="metric.key" :class="metric.tone">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<small :title="metric.detail">{{ metric.detail }}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="connector-health__evidence" aria-label="付款证据可信等级说明">
|
||||
<strong>证据口径</strong>
|
||||
<span class="is-external"><i class="mdi mdi-shield-check-outline"></i>生产外部回执 · 高可信现金事实</span>
|
||||
<span class="is-manual"><i class="mdi mdi-account-check-outline"></i>人工付款确认 · 低等级内部状态</span>
|
||||
<span class="is-simulated"><i class="mdi mdi-flask-outline"></i>模拟/预发布回执 · 仅验证,不入核心账</span>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="connector-health__table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>来源</th>
|
||||
<th>环境 / 证据</th>
|
||||
<th>状态</th>
|
||||
<th>最近成功</th>
|
||||
<th>事件</th>
|
||||
<th>失败率</th>
|
||||
<th>积压 / 异常</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.config_id">
|
||||
<td><strong>{{ item.provider }}</strong><small>{{ item.key_version }}</small></td>
|
||||
<td><span :class="['evidence-chip', evidenceTone(item)]">{{ item.evidence_label }}</span></td>
|
||||
<td><span :class="['status-chip', item.status]">{{ statusLabel(item.status) }}</span></td>
|
||||
<td>{{ formatTime(item.last_success_at) }}</td>
|
||||
<td>{{ number(item.event_count) }}</td>
|
||||
<td>{{ percent(item.failure_rate) }}</td>
|
||||
<td>{{ number(item.backlog_count) }} / {{ number(item.reconciliation_anomaly_count) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="connector-health__state">
|
||||
<i class="mdi mdi-lan-disconnect"></i>
|
||||
<span>当前租户尚未配置财务连接器。</span>
|
||||
</div>
|
||||
<footer v-if="payload">
|
||||
统计窗口 {{ payload.window_hours || 24 }} 小时 · 更新于 {{ formatTime(payload.generated_at) }}
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { fetchFinancialConnectorObservability } from '../../services/financialConnectors.js'
|
||||
|
||||
const props = defineProps({
|
||||
currentUser: { type: Object, default: () => ({}) },
|
||||
windowHours: { type: Number, default: 24 }
|
||||
})
|
||||
|
||||
const payload = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
let requestSequence = 0
|
||||
|
||||
const roleCodes = computed(() => (
|
||||
Array.isArray(props.currentUser?.roleCodes)
|
||||
? props.currentUser.roleCodes.map((item) => String(item || '').trim().toLowerCase())
|
||||
: []
|
||||
))
|
||||
const canView = computed(() => (
|
||||
Boolean(props.currentUser?.isAdmin)
|
||||
|| roleCodes.value.includes('finance')
|
||||
|| roleCodes.value.includes('executive')
|
||||
))
|
||||
const summary = computed(() => payload.value?.summary || {})
|
||||
const items = computed(() => Array.isArray(payload.value?.items) ? payload.value.items : [])
|
||||
const lastSuccess = computed(() => {
|
||||
const timestamps = items.value
|
||||
.map((item) => Date.parse(item.last_success_at || ''))
|
||||
.filter(Number.isFinite)
|
||||
return timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : ''
|
||||
})
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
key: 'last-success',
|
||||
label: '最后成功',
|
||||
value: formatTime(lastSuccess.value),
|
||||
detail: '最近一次已处理连接器事实',
|
||||
tone: 'is-good'
|
||||
},
|
||||
{
|
||||
key: 'failure-rate',
|
||||
label: '失败率',
|
||||
value: percent(summary.value.failure_rate),
|
||||
detail: `${number(summary.value.failed_event_count)} / ${number(summary.value.event_count)} 个事件`,
|
||||
tone: Number(summary.value.failed_event_count || 0) ? 'is-warning' : 'is-good'
|
||||
},
|
||||
{
|
||||
key: 'retry',
|
||||
label: '重试',
|
||||
value: summary.value.retry_count == null ? '待耐久遥测' : number(summary.value.retry_count),
|
||||
detail: payload.value?.retry_metric?.reason || '已采集重试次数',
|
||||
tone: summary.value.retry_count == null ? 'is-neutral' : 'is-good'
|
||||
},
|
||||
{
|
||||
key: 'backlog',
|
||||
label: '积压',
|
||||
value: number(summary.value.backlog_count),
|
||||
detail: '处理中或等待前置事实的事件',
|
||||
tone: Number(summary.value.backlog_count || 0) ? 'is-warning' : 'is-good'
|
||||
},
|
||||
{
|
||||
key: 'signature',
|
||||
label: '签名失败',
|
||||
value: summary.value.signature_failure_count == null ? '待耐久遥测' : number(summary.value.signature_failure_count),
|
||||
detail: payload.value?.signature_failure_metric?.reason || '已采集签名失败次数',
|
||||
tone: summary.value.signature_failure_count == null ? 'is-neutral' : 'is-good'
|
||||
},
|
||||
{
|
||||
key: 'reconciliation',
|
||||
label: '对账异常',
|
||||
value: number(summary.value.reconciliation_anomaly_count),
|
||||
detail: 'exception / reopened 对账记录',
|
||||
tone: Number(summary.value.reconciliation_anomaly_count || 0) ? 'is-warning' : 'is-good'
|
||||
}
|
||||
])
|
||||
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
const currentSequence = ++requestSequence
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await fetchFinancialConnectorObservability({ windowHours: props.windowHours })
|
||||
if (currentSequence === requestSequence) payload.value = result
|
||||
} catch (loadError) {
|
||||
if (currentSequence === requestSequence) {
|
||||
error.value = String(loadError?.message || '财务连接器指标加载失败。')
|
||||
}
|
||||
} finally {
|
||||
if (currentSequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
return new Intl.NumberFormat('zh-CN').format(Number(value || 0))
|
||||
}
|
||||
|
||||
function percent(value) {
|
||||
return `${(Number(value || 0) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
const timestamp = Date.parse(String(value || ''))
|
||||
if (!Number.isFinite(timestamp)) return '暂无成功'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
|
||||
}).format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function statusLabel(value) {
|
||||
return ({ active: '运行中', disabled: '已停用', rotating: '轮换中' })[value] || '未知'
|
||||
}
|
||||
|
||||
function evidenceTone(item) {
|
||||
if (item?.evidence_classification === 'external_cash') return 'is-external'
|
||||
if (item?.evidence_classification === 'staging_connector') return 'is-staging'
|
||||
return 'is-simulated'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => [canView.value, props.windowHours], load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connector-health { margin: 18px 0; padding: 20px; display: grid; gap: 18px; }
|
||||
.connector-health__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.connector-health__head h3 { margin: 3px 0 5px; font-size: 20px; }
|
||||
.connector-health__head p, .connector-health footer { margin: 0; color: var(--text-secondary); font-size: 13px; }
|
||||
.connector-health__eyebrow { color: var(--theme-primary); font-size: 11px; font-weight: 800; letter-spacing: .12em; }
|
||||
.connector-health__head button { border: 1px solid var(--border-color); border-radius: 10px; padding: 8px 12px; background: var(--surface); color: var(--text-primary); cursor: pointer; }
|
||||
.connector-health__head button:disabled { cursor: wait; opacity: .6; }
|
||||
.connector-health__metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
|
||||
.connector-health__metrics article { padding: 13px; border: 1px solid var(--border-color); border-radius: 12px; background: color-mix(in srgb, var(--surface) 88%, transparent); }
|
||||
.connector-health__metrics article.is-warning { border-color: color-mix(in srgb, #f59e0b 45%, var(--border-color)); }
|
||||
.connector-health__metrics article.is-neutral { border-style: dashed; }
|
||||
.connector-health__metrics span, .connector-health__metrics small { display: block; color: var(--text-secondary); }
|
||||
.connector-health__metrics strong { display: block; margin: 6px 0 4px; font-size: 18px; }
|
||||
.connector-health__metrics small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
|
||||
.connector-health__evidence { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.connector-health__evidence span, .evidence-chip, .status-chip { border-radius: 999px; padding: 5px 9px; font-weight: 700; }
|
||||
.is-external { color: #047857; background: #ecfdf5; }
|
||||
.is-manual { color: #b45309; background: #fffbeb; }
|
||||
.is-simulated, .evidence-chip.is-simulated { color: #475569; background: #f1f5f9; }
|
||||
.evidence-chip.is-staging { color: #6d28d9; background: #f5f3ff; }
|
||||
.status-chip.active { color: #047857; background: #ecfdf5; }
|
||||
.status-chip.disabled { color: #64748b; background: #f1f5f9; }
|
||||
.status-chip.rotating { color: #a16207; background: #fefce8; }
|
||||
.connector-health__table-wrap { overflow-x: auto; }
|
||||
.connector-health table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.connector-health th, .connector-health td { padding: 11px 9px; border-bottom: 1px solid var(--border-color); text-align: left; white-space: nowrap; }
|
||||
.connector-health td small { display: block; margin-top: 3px; color: var(--text-secondary); }
|
||||
.connector-health__state { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 84px; color: var(--text-secondary); }
|
||||
.connector-health__state.is-error { color: #b91c1c; }
|
||||
@media (max-width: 1180px) { .connector-health__metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) {
|
||||
.connector-health { padding: 16px; }
|
||||
.connector-health__head { align-items: stretch; flex-direction: column; }
|
||||
.connector-health__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user