feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View 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>

View 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>

View 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>

View 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>