feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -110,6 +110,17 @@
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<AuditReleaseMonitorPanel
|
||||
:release-state="releaseState"
|
||||
:review-queue="releaseQueue"
|
||||
:loading="releaseLoading"
|
||||
:error="releaseError"
|
||||
:action="releaseAction"
|
||||
:can-review="canReviewRelease"
|
||||
@refresh="emit('refresh-release')"
|
||||
@label="(observationId, label) => emit('label-release', observationId, label)"
|
||||
/>
|
||||
|
||||
<article class="detail-card panel json-risk-flow-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
@@ -132,6 +143,7 @@
|
||||
|
||||
<script setup>
|
||||
import RiskRuleFlowDiagram from '../shared/RiskRuleFlowDiagram.vue'
|
||||
import AuditReleaseMonitorPanel from './AuditReleaseMonitorPanel.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'AuditJsonRiskRuleDetail'
|
||||
@@ -139,8 +151,16 @@ defineOptions({
|
||||
|
||||
defineProps({
|
||||
selectedSkill: { type: Object, required: true },
|
||||
riskRuleTestPassed: { type: Boolean, default: false }
|
||||
riskRuleTestPassed: { type: Boolean, default: false },
|
||||
releaseState: { type: Object, default: null },
|
||||
releaseQueue: { type: Object, default: null },
|
||||
releaseLoading: { type: Boolean, default: false },
|
||||
releaseError: { type: String, default: '' },
|
||||
releaseAction: { type: String, default: '' },
|
||||
canReviewRelease: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh-release', 'label-release'])
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/views/audit-view.css"></style>
|
||||
|
||||
206
web/src/components/audit/AuditReleaseMonitorPanel.vue
Normal file
206
web/src/components/audit/AuditReleaseMonitorPanel.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<article
|
||||
v-if="loading || error || releaseState || reviewQueue"
|
||||
class="detail-card panel release-monitor-card"
|
||||
>
|
||||
<div class="release-monitor-head">
|
||||
<div>
|
||||
<p class="release-monitor-eyebrow">AI 发布控制</p>
|
||||
<h3>真实运行遥测与独立盲审</h3>
|
||||
<p>正负样本随机混排,候选与基线结论对复核人隐藏;证据不足时不会自动晋级。</p>
|
||||
</div>
|
||||
<button type="button" class="release-refresh" :disabled="loading" @click="emit('refresh')">
|
||||
<i class="mdi mdi-refresh" :class="{ spinning: loading }"></i>
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !reviewQueue" class="release-state-note" role="status">
|
||||
正在聚合当前发布阶段的真实样本…
|
||||
</div>
|
||||
<div v-else-if="error" class="release-state-note error" role="alert">{{ error }}</div>
|
||||
|
||||
<template v-if="releaseState || reviewQueue">
|
||||
<div class="release-identity">
|
||||
<span :class="['release-stage', stageTone]">{{ stageLabel }}</span>
|
||||
<span>候选版本 {{ reviewQueue?.version || releaseState?.candidate_version || '-' }}</span>
|
||||
<span>稳定版本 {{ releaseState?.previous_version || '-' }}</span>
|
||||
<span>{{ telemetryLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="release-metric-grid">
|
||||
<div v-for="metric in metricCards" :key="metric.label" class="release-metric">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reviewQueue?.alerts?.length" class="release-alert-list" aria-label="发布告警">
|
||||
<div
|
||||
v-for="alert in reviewQueue.alerts"
|
||||
:key="alert.code"
|
||||
:class="['release-alert', `is-${alert.severity}`]"
|
||||
>
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<div>
|
||||
<strong>{{ alert.message }}</strong>
|
||||
<span>{{ alert.recommended_action }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="release-review-section">
|
||||
<div class="release-review-title">
|
||||
<div>
|
||||
<h4>待复核样本</h4>
|
||||
<p>发布发起人不能复核自己的版本;请只依据源单据和规则判断是否存在真实风险。</p>
|
||||
</div>
|
||||
<b>{{ reviewQueue?.pending_total || 0 }} 条</b>
|
||||
</div>
|
||||
|
||||
<div v-if="reviewQueue?.items?.length" class="release-review-list">
|
||||
<div v-for="item in reviewQueue.items" :key="item.sample_id || item.observation_id" class="release-review-row">
|
||||
<div class="release-review-meta">
|
||||
<strong>{{ businessStageLabel(item.business_stage) }}</strong>
|
||||
<span>{{ formatDate(item.created_at) }}</span>
|
||||
<small>
|
||||
规则 {{ item.rule_code || '未标识' }} ·
|
||||
复核 {{ item.reviewer_count || 0 }}/{{ item.required_reviewers || 1 }}
|
||||
</small>
|
||||
<small class="release-review-blind">正负样本随机混排 · 模型结论已隐藏</small>
|
||||
<RouterLink
|
||||
v-if="sourceDocumentRoute(item)"
|
||||
class="release-source-link"
|
||||
:to="sourceDocumentRoute(item)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="mdi mdi-open-in-new" aria-hidden="true"></i>
|
||||
新标签查看源单据
|
||||
</RouterLink>
|
||||
<small v-if="item.conflicted" class="release-review-conflict">复核结论存在分歧,需要继续仲裁。</small>
|
||||
</div>
|
||||
<div class="release-review-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="review-decision"
|
||||
:disabled="!canReview || Boolean(action)"
|
||||
@click="emit('label', item.observation_id, 'risk_present')"
|
||||
>
|
||||
{{ action === `${item.observation_id}:risk_present` ? '提交中…' : '存在真实风险' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="review-decision"
|
||||
:disabled="!canReview || Boolean(action)"
|
||||
@click="emit('label', item.observation_id, 'risk_absent')"
|
||||
>
|
||||
{{ action === `${item.observation_id}:risk_absent` ? '提交中…' : '确认无该风险' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="release-review-empty">
|
||||
<i class="mdi mdi-check-decagram-outline"></i>
|
||||
<span>当前没有待复核盲审样本;召回率只在正负样本独立标注证据就绪后展示。</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
buildReleaseMonitorMetricCards,
|
||||
buildReleaseReviewDocumentRoute
|
||||
} from '../../views/scripts/useAuditReleaseMonitor.js'
|
||||
|
||||
const props = defineProps({
|
||||
releaseState: { type: Object, default: null },
|
||||
reviewQueue: { type: Object, default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
action: { type: String, default: '' },
|
||||
canReview: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh', 'label'])
|
||||
|
||||
const stageLabel = computed(() => ({
|
||||
shadow: '影子发布',
|
||||
canary: '灰度发布',
|
||||
active: '正式运行',
|
||||
rolled_back: '已回滚'
|
||||
}[props.releaseState?.stage || props.reviewQueue?.stage] || '未受控'))
|
||||
|
||||
const stageTone = computed(() => `is-${props.releaseState?.stage || props.reviewQueue?.stage || 'unknown'}`)
|
||||
const telemetryLabel = computed(() => (
|
||||
props.reviewQueue?.telemetry_status === 'ready' ? '指标已就绪' : '证据采集中'
|
||||
))
|
||||
|
||||
const metricCards = computed(() => buildReleaseMonitorMetricCards(props.reviewQueue || {}))
|
||||
|
||||
function sourceDocumentRoute(item) {
|
||||
return buildReleaseReviewDocumentRoute(item)
|
||||
}
|
||||
|
||||
function businessStageLabel(value) {
|
||||
return value === 'expense_application' ? '费用申请' : '费用报销'
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.release-monitor-card { display: grid; gap: 18px; }
|
||||
.release-monitor-head, .release-review-title, .release-review-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||
.release-monitor-head h3, .release-review-title h4 { margin: 2px 0 6px; color: var(--text-primary); }
|
||||
.release-monitor-head p, .release-review-title p { margin: 0; color: var(--text-muted); line-height: 1.6; }
|
||||
.release-monitor-eyebrow { color: var(--theme-primary) !important; font-size: 12px; font-weight: 800; letter-spacing: .08em; }
|
||||
.release-refresh { min-height: 44px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 8px; padding: 0 14px; background: var(--surface); color: var(--text-primary); }
|
||||
.spinning { animation: spin 800ms linear infinite; }
|
||||
.release-identity { display: flex; flex-wrap: wrap; gap: 8px; color: var(--text-muted); font-size: 13px; }
|
||||
.release-identity > span { padding: 7px 10px; border-radius: 6px; background: var(--surface-muted); }
|
||||
.release-stage { font-weight: 800; color: var(--theme-primary) !important; }
|
||||
.release-stage.is-rolled_back { color: var(--danger) !important; }
|
||||
.release-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.release-metric { min-height: 102px; display: grid; align-content: center; gap: 4px; padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
||||
.release-metric span, .release-metric small { color: var(--text-muted); }
|
||||
.release-metric strong { font-size: 22px; color: var(--text-primary); }
|
||||
.release-alert-list, .release-review-list { display: grid; gap: 10px; }
|
||||
.release-alert { display: flex; gap: 10px; padding: 12px 14px; border: 1px solid var(--line); border-left: 4px solid var(--theme-primary); border-radius: 7px; background: var(--surface); }
|
||||
.release-alert.is-warning { border-left-color: #d97706; }
|
||||
.release-alert.is-error, .release-alert.is-critical { border-left-color: var(--danger); }
|
||||
.release-alert div { display: grid; gap: 3px; }
|
||||
.release-alert span { color: var(--text-muted); font-size: 13px; }
|
||||
.release-review-section { display: grid; gap: 14px; padding-top: 4px; }
|
||||
.release-review-title b { min-width: 52px; padding: 7px 10px; border-radius: 999px; background: var(--theme-primary-soft); color: var(--theme-primary); text-align: center; }
|
||||
.release-review-row { padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
||||
.release-review-meta { display: grid; gap: 4px; }
|
||||
.release-review-meta span, .release-review-meta small { color: var(--text-muted); }
|
||||
.release-review-blind { font-weight: 700; }
|
||||
.release-source-link { width: fit-content; min-height: 44px; display: inline-flex; align-items: center; gap: 5px; margin-top: 3px; color: var(--theme-primary); font-size: 13px; font-weight: 700; text-decoration: none; }
|
||||
.release-source-link:hover, .release-source-link:focus-visible { text-decoration: underline; outline: none; }
|
||||
.release-review-conflict { color: var(--danger) !important; font-weight: 700; }
|
||||
.release-review-actions { display: flex; gap: 8px; }
|
||||
.release-review-actions button { min-height: 44px; border-radius: 7px; padding: 0 13px; font-weight: 700; }
|
||||
.release-review-actions .review-decision { border: 1px solid var(--line); background: var(--surface); color: var(--text-primary); }
|
||||
.release-review-actions .review-decision:hover, .release-review-actions .review-decision:focus-visible { border-color: var(--theme-primary); color: var(--theme-primary); outline: none; }
|
||||
.release-review-actions button:disabled, .release-refresh:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.release-review-empty, .release-state-note { display: flex; align-items: center; gap: 9px; padding: 14px; border-radius: 8px; background: var(--surface-muted); color: var(--text-muted); }
|
||||
.release-state-note.error { color: var(--danger); background: rgba(220, 38, 38, .07); }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 960px) { .release-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) {
|
||||
.release-monitor-head, .release-review-title, .release-review-row { flex-direction: column; }
|
||||
.release-metric-grid { grid-template-columns: 1fr; }
|
||||
.release-review-actions { width: 100%; }
|
||||
.release-review-actions button { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
158
web/src/components/charts/CfoValueTrendChart.vue
Normal file
158
web/src/components/charts/CfoValueTrendChart.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="cfo-value-trend-chart">
|
||||
<div class="value-trend-legend" aria-label="趋势图图例">
|
||||
<span><i class="verified"></i>财务已确认(实线)</span>
|
||||
<span><i class="pending"></i>实际待确认(虚线)</span>
|
||||
<span><i class="reversal"></i>冲回(点线)</span>
|
||||
<em>单位:{{ currency || '—' }}</em>
|
||||
</div>
|
||||
<div v-if="rows.length" ref="chartElement" class="value-trend-canvas" role="img" :aria-label="ariaLabel"></div>
|
||||
<div v-else class="value-trend-empty" role="status">
|
||||
<i class="mdi mdi-chart-line-variant" aria-hidden="true"></i>
|
||||
<span>当前筛选范围暂无实际结果趋势</span>
|
||||
</div>
|
||||
|
||||
<details v-if="rows.length" class="value-trend-table-wrap">
|
||||
<summary>查看趋势明细表</summary>
|
||||
<div class="value-trend-table-scroll">
|
||||
<table>
|
||||
<caption class="sr-only">{{ currency }} 经营价值趋势明细</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">期间</th>
|
||||
<th scope="col">财务已确认</th>
|
||||
<th scope="col">实际待确认</th>
|
||||
<th scope="col">冲回</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="`${row.period}-${row.currency}`">
|
||||
<th scope="row">{{ row.period }}</th>
|
||||
<td>{{ formatMoney(row.verifiedNet, row.currency) }}</td>
|
||||
<td>{{ formatMoney(row.actualPending, row.currency) }}</td>
|
||||
<td>{{ formatMoney(row.reversal, row.currency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { AriaComponent, GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
import { formatMoney } from '../../views/scripts/cfoValueDashboardModel.js'
|
||||
|
||||
use([AriaComponent, GridComponent, TooltipComponent, LineChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
rows: { type: Array, default: () => [] },
|
||||
currency: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const reduceMotion = typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const ariaLabel = computed(() => props.rows.map((row) => (
|
||||
`${row.period},财务已确认${formatMoney(row.verifiedNet, row.currency)}`
|
||||
+ `,实际待确认${formatMoney(row.actualPending, row.currency)}`
|
||||
+ `,冲回${formatMoney(row.reversal, row.currency)}`
|
||||
)).join(';'))
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
animation: !reduceMotion,
|
||||
animationDuration: reduceMotion ? 0 : 260,
|
||||
aria: {
|
||||
enabled: true,
|
||||
decal: { show: true },
|
||||
description: ariaLabel.value
|
||||
},
|
||||
grid: { top: 20, right: 24, bottom: 20, left: 30, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#0f172a',
|
||||
borderWidth: 0,
|
||||
textStyle: { color: '#ffffff', fontSize: 12 },
|
||||
valueFormatter: (value) => formatMoney(value, props.currency)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.rows.map((row) => row.period),
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#64748b', fontSize: 12, hideOverlap: true }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitNumber: 4,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#64748b', fontSize: 12, formatter: compactNumber },
|
||||
splitLine: { lineStyle: { color: '#e2e8f0', type: 'dashed' } }
|
||||
},
|
||||
series: [
|
||||
buildSeries('财务已确认', props.rows.map((row) => row.verifiedNet), themeColors.value.success, 'solid'),
|
||||
buildSeries('实际待确认', props.rows.map((row) => row.actualPending), themeColors.value.chartAmber, 'dashed'),
|
||||
buildSeries('冲回', props.rows.map((row) => row.reversal), themeColors.value.chartDanger, 'dotted')
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
|
||||
function buildSeries(name, data, color, type) {
|
||||
return {
|
||||
name,
|
||||
type: 'line',
|
||||
data,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
lineStyle: { width: 2.5, color, type },
|
||||
itemStyle: { color: '#ffffff', borderColor: color, borderWidth: 2 },
|
||||
emphasis: { focus: 'series' }
|
||||
}
|
||||
}
|
||||
|
||||
function compactNumber(value) {
|
||||
const number = Number(value || 0)
|
||||
if (Math.abs(number) >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}m`
|
||||
if (Math.abs(number) >= 1_000) return `${(number / 1_000).toFixed(1)}k`
|
||||
return number.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cfo-value-trend-chart { display: grid; gap: 12px; min-width: 0; }
|
||||
.value-trend-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; color: var(--muted); font-size: 12px; }
|
||||
.value-trend-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.value-trend-legend i { width: 20px; height: 0; border-top: 3px solid; }
|
||||
.value-trend-legend i.verified { border-color: var(--success); }
|
||||
.value-trend-legend i.pending { border-color: var(--warning); border-top-style: dashed; }
|
||||
.value-trend-legend i.reversal { border-color: var(--danger); border-top-style: dotted; }
|
||||
.value-trend-legend em { margin-left: auto; color: var(--text); font-style: normal; font-weight: 700; }
|
||||
.value-trend-canvas { width: 100%; min-height: 290px; }
|
||||
.value-trend-empty { min-height: 290px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--muted); }
|
||||
.value-trend-empty i { font-size: 32px; color: var(--line-strong); }
|
||||
.value-trend-table-wrap { border-top: 1px solid var(--line); padding-top: 10px; }
|
||||
.value-trend-table-wrap summary { min-height: 44px; display: inline-flex; align-items: center; color: var(--theme-primary-active); font-size: 12px; font-weight: 750; cursor: pointer; }
|
||||
.value-trend-table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 560px; }
|
||||
th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); color: var(--text); font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
thead th { color: var(--muted); font-weight: 700; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.value-trend-legend em { width: 100%; margin-left: 0; }
|
||||
.value-trend-canvas, .value-trend-empty { min-height: 240px; }
|
||||
}
|
||||
</style>
|
||||
122
web/src/components/commercial/CommercialAccountOverview.vue
Normal file
122
web/src/components/commercial/CommercialAccountOverview.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<section class="commercial-section" aria-labelledby="commercial-account-title">
|
||||
<header class="commercial-section-head">
|
||||
<div>
|
||||
<span>Commercial account</span>
|
||||
<h3 id="commercial-account-title">套餐、订阅与权益</h3>
|
||||
<p>当前状态由服务器按租户、版本和生效时间计算,缺配置不会被当作免费或不限量。</p>
|
||||
</div>
|
||||
<button type="button" class="commercial-button secondary" :disabled="loading" @click="$emit('retry')">
|
||||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'" aria-hidden="true"></i>
|
||||
{{ loading ? '同步中' : '刷新账户' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="state === 'loading'" class="commercial-state-card" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
<div><strong>正在加载商业账户</strong><span>不会在请求期间展示演示套餐或默认配额。</span></div>
|
||||
</div>
|
||||
<div v-else-if="state === 'permission'" class="commercial-state-card danger" role="alert">
|
||||
<i class="mdi mdi-shield-lock-outline" aria-hidden="true"></i>
|
||||
<div><strong>当前账号无权读取商业账户</strong><span>{{ error?.message || '请使用获授权的财务、管理层或平台管理员账号。' }}</span></div>
|
||||
</div>
|
||||
<div v-else-if="state === 'error'" class="commercial-state-card danger" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
|
||||
<div><strong>商业账户加载失败</strong><span>{{ error?.message || '请检查网络后重试。' }}</span></div>
|
||||
<button type="button" class="commercial-button secondary" @click="$emit('retry')">重新加载</button>
|
||||
</div>
|
||||
<div v-else-if="state === 'unavailable'" class="commercial-state-card warning" role="status">
|
||||
<i class="mdi mdi-database-off-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>尚无可用的商业订阅</strong>
|
||||
<span>{{ account?.notes?.join(';') || '平台尚未为该租户配置当前订阅,这不是零费用或无限权益。' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="state === 'partial'" class="commercial-inline-warning" role="status">
|
||||
<i class="mdi mdi-alert-outline" aria-hidden="true"></i>
|
||||
{{ account?.notes?.join(';') || '商业账户信息不完整,相关能力将失败关闭。' }}
|
||||
</div>
|
||||
|
||||
<div class="commercial-account-grid">
|
||||
<article class="commercial-account-card">
|
||||
<span>当前套餐</span>
|
||||
<div class="commercial-card-title">
|
||||
<strong>{{ account?.plan?.name || '套餐信息缺失' }}</strong>
|
||||
<em>{{ statusLabel(account?.plan?.status) }}</em>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>套餐编码</dt><dd>{{ account?.plan?.planCode || '—' }}</dd></div>
|
||||
<div><dt>计价模式</dt><dd>{{ account?.plan?.pricingModel || '—' }}</dd></div>
|
||||
<div><dt>计费周期</dt><dd>{{ account?.plan?.billingInterval || '—' }}</dd></div>
|
||||
<div><dt>基础费用</dt><dd>{{ formatCurrencyAmount(account?.plan?.baseFee, account?.plan?.currency) }}</dd></div>
|
||||
<div><dt>版本</dt><dd>v{{ account?.plan?.version || '—' }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="commercial-account-card">
|
||||
<span>当前订阅</span>
|
||||
<div class="commercial-card-title">
|
||||
<strong>{{ account?.subscription?.subscriptionKey || '订阅信息缺失' }}</strong>
|
||||
<em>{{ statusLabel(account?.subscription?.status) }}</em>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>席位数</dt><dd>{{ account?.subscription?.seats ?? '—' }}</dd></div>
|
||||
<div><dt>自动续费</dt><dd>{{ account?.subscription?.autoRenew ? '已开启' : '未开启' }}</dd></div>
|
||||
<div><dt>周期开始</dt><dd>{{ formatDateTime(account?.subscription?.currentPeriodStart) }}</dd></div>
|
||||
<div><dt>周期结束</dt><dd>{{ formatDateTime(account?.subscription?.currentPeriodEnd) }}</dd></div>
|
||||
<div><dt>版本</dt><dd>v{{ account?.subscription?.version || '—' }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="commercial-quota-panel">
|
||||
<header>
|
||||
<div><h4>权益与当前周期用量</h4><p>硬配额耗尽或配置不完整时,商业门禁会拒绝继续消费。</p></div>
|
||||
<span>{{ quotaRows.length }} 项权益</span>
|
||||
</header>
|
||||
<div v-if="quotaRows.length" class="commercial-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th scope="col">权益</th><th scope="col">计量指标</th><th scope="col">已用</th><th scope="col">硬配额余量</th><th scope="col">状态</th><th scope="col">说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in quotaRows" :key="item.id || item.key">
|
||||
<td><strong>{{ item.key }}</strong><small>v{{ item.version }}</small></td>
|
||||
<td>{{ item.metricKey }}<small>{{ item.unit }}</small></td>
|
||||
<td class="numeric">{{ item.usedLabel }}</td>
|
||||
<td class="numeric">{{ item.remainingLabel }}</td>
|
||||
<td><span :class="['commercial-status-pill', item.status]">{{ item.statusLabel }}</span></td>
|
||||
<td>{{ item.reason || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="commercial-table-empty" role="status">
|
||||
<i class="mdi mdi-gauge-empty" aria-hidden="true"></i>
|
||||
<span>当前订阅尚未配置权益;所有需要商业授权的能力都应失败关闭。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatCurrencyAmount, statusLabel } from './commercialWorkspaceModel.js'
|
||||
|
||||
defineProps({
|
||||
account: { type: Object, default: null },
|
||||
state: { type: String, required: true },
|
||||
error: { type: Object, default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
quotaRows: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
defineEmits(['retry'])
|
||||
|
||||
function formatDateTime(value) {
|
||||
const parsed = new Date(value)
|
||||
return Number.isFinite(parsed.getTime())
|
||||
? parsed.toLocaleString('zh-CN', { hour12: false })
|
||||
: '—'
|
||||
}
|
||||
</script>
|
||||
105
web/src/components/commercial/CommercialAdminConsole.vue
Normal file
105
web/src/components/commercial/CommercialAdminConsole.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<section class="commercial-section commercial-admin-section" aria-labelledby="commercial-admin-title">
|
||||
<header class="commercial-section-head">
|
||||
<div>
|
||||
<span>Platform administration</span>
|
||||
<h3 id="commercial-admin-title">商业配置与事实台账</h3>
|
||||
<p>配置采用版本化更新,用量和成本采用不可变事件;所有操作提交前都必须二次确认。</p>
|
||||
</div>
|
||||
<span :class="['commercial-admin-access', canManage ? 'allowed' : 'denied']">
|
||||
<i :class="canManage ? 'mdi mdi-shield-check-outline' : 'mdi mdi-shield-lock-outline'" aria-hidden="true"></i>
|
||||
{{ canManage ? '平台管理员' : '只读' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="!canManage" class="commercial-state-card permission" role="status">
|
||||
<i class="mdi mdi-lock-outline" aria-hidden="true"></i>
|
||||
<div><strong>当前页面为只读模式</strong><span>只有平台管理员且已选择目标租户时,才可修改套餐、订阅、权益及事实台账。</span></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="message" class="commercial-operation-feedback success" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>{{ message }}
|
||||
</div>
|
||||
<div v-if="error" class="commercial-operation-feedback danger" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>{{ error.message || '操作失败,请刷新后重试。' }}
|
||||
</div>
|
||||
|
||||
<div class="commercial-admin-groups">
|
||||
<article>
|
||||
<header><i class="mdi mdi-source-branch" aria-hidden="true"></i><div><strong>版本化配置</strong><span>套餐、订阅与权益</span></div></header>
|
||||
<div>
|
||||
<button v-for="action in configurationActions" :key="action.kind" type="button" class="commercial-action-button" :disabled="busy" @click="openAction(action.kind)">
|
||||
<span>{{ action.label }}</span><i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<header><i class="mdi mdi-database-lock-outline" aria-hidden="true"></i><div><strong>不可变事实</strong><span>用量与平台内部成本</span></div></header>
|
||||
<div>
|
||||
<button v-for="action in meteringActions" :key="action.kind" type="button" class="commercial-action-button" :disabled="busy" @click="openAction(action.kind)">
|
||||
<span>{{ action.label }}</span><i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<CommercialMutationDialog
|
||||
:open="dialogOpen"
|
||||
:kind="selectedAction"
|
||||
:account="account"
|
||||
:preset="actionPreset"
|
||||
:busy="busy"
|
||||
@close="dialogOpen = false"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CommercialMutationDialog from './CommercialMutationDialog.vue'
|
||||
import { COMMERCIAL_ACTIONS } from './commercialWorkspaceModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
canManage: { type: Boolean, default: false },
|
||||
account: { type: Object, default: null },
|
||||
busy: { type: Boolean, default: false },
|
||||
error: { type: Object, default: null },
|
||||
message: { type: String, default: '' },
|
||||
execute: { type: Function, required: true }
|
||||
})
|
||||
|
||||
const configurationActions = [
|
||||
'createPlan',
|
||||
'activatePlan',
|
||||
'createSubscription',
|
||||
'activateSubscription',
|
||||
'transitionSubscription',
|
||||
'upsertEntitlement',
|
||||
'activateEntitlement'
|
||||
].map((kind) => ({ kind, ...COMMERCIAL_ACTIONS[kind] }))
|
||||
const meteringActions = ['recordUsage', 'recordCost']
|
||||
.map((kind) => ({ kind, ...COMMERCIAL_ACTIONS[kind] }))
|
||||
const dialogOpen = ref(false)
|
||||
const selectedAction = ref('')
|
||||
const actionPreset = ref({})
|
||||
|
||||
function openAction(kind, preset = {}) {
|
||||
selectedAction.value = kind
|
||||
actionPreset.value = { ...preset }
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(command, reportError) {
|
||||
try {
|
||||
await props.execute(command.kind, command.form)
|
||||
dialogOpen.value = false
|
||||
} catch (error) {
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openAction })
|
||||
</script>
|
||||
127
web/src/components/commercial/CommercialHistoryPanel.vue
Normal file
127
web/src/components/commercial/CommercialHistoryPanel.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<section class="commercial-section commercial-history-section" aria-labelledby="commercial-history-title">
|
||||
<header class="commercial-section-head">
|
||||
<div>
|
||||
<span>Audit-ready history</span>
|
||||
<h3 id="commercial-history-title">商业版本与事实历史</h3>
|
||||
<p>套餐、订阅和权益保留版本;当前按服务器倒序加载配置最多 100/200 条、事件最多 200 条,不伪装成全量导出。</p>
|
||||
</div>
|
||||
<button type="button" class="commercial-button secondary" :disabled="loading || !platformAdmin" @click="$emit('load')">
|
||||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-history'" aria-hidden="true"></i>
|
||||
{{ loading ? '加载中' : loaded ? '刷新历史' : '加载历史' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="!platformAdmin" class="commercial-state-card permission" role="status">
|
||||
<i class="mdi mdi-shield-lock-outline" aria-hidden="true"></i>
|
||||
<div><strong>商业历史仅向平台管理员开放</strong><span>租户普通用户不会收到平台成本、其他版本或内部计量元数据。</span></div>
|
||||
</div>
|
||||
<div v-else-if="loading && !loaded" class="commercial-state-card" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
<div><strong>正在加载五类商业历史</strong><span>套餐、订阅、权益、用量和成本分别从租户隔离接口读取。</span></div>
|
||||
</div>
|
||||
<div v-else-if="!loaded" class="commercial-history-empty" role="status">
|
||||
<i class="mdi mdi-history" aria-hidden="true"></i>
|
||||
<strong>历史记录按需加载</strong>
|
||||
<span>点击“加载历史”查看服务器真实版本和事件;页面不会生成示例记录。</span>
|
||||
<button type="button" class="commercial-button secondary" @click="$emit('load')">加载历史</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="error" class="commercial-operation-feedback danger" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>{{ error.message }}
|
||||
</div>
|
||||
<div class="commercial-history-tabs" role="group" aria-label="商业历史类型">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
:aria-pressed="activeTab === tab.key ? 'true' : 'false'"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>{{ tab.label }} <span>{{ history[tab.key]?.length || 0 }}</span></button>
|
||||
</div>
|
||||
|
||||
<div v-if="!activeRows.length" class="commercial-table-empty commercial-history-table-empty" role="status">
|
||||
<i class="mdi mdi-database-off-outline" aria-hidden="true"></i>
|
||||
<span>{{ activeTabLabel }}暂无服务器记录;这不是零用量或零成本。</span>
|
||||
</div>
|
||||
<div v-else class="commercial-table-wrap commercial-history-table">
|
||||
<table v-if="activeTab === 'plans'">
|
||||
<thead><tr><th scope="col">套餐</th><th scope="col">状态</th><th scope="col">计价</th><th scope="col">费用</th><th scope="col">版本</th><th scope="col">创建时间</th></tr></thead>
|
||||
<tbody><tr v-for="item in activeRows" :key="item.id"><td><strong>{{ item.name }}</strong><small>{{ item.planCode }}</small></td><td><span :class="['commercial-status-pill', item.status]">{{ statusLabel(item.status) }}</span></td><td>{{ item.pricingModel }}<small>{{ item.billingInterval }}</small></td><td class="numeric">{{ formatCurrencyAmount(item.baseFee, item.currency) }}</td><td>v{{ item.version }}</td><td>{{ formatDateTime(item.createdAt) }}</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<table v-else-if="activeTab === 'subscriptions'">
|
||||
<thead><tr><th scope="col">订阅</th><th scope="col">状态</th><th scope="col">当前周期</th><th scope="col">席位</th><th scope="col">版本</th><th scope="col">操作</th></tr></thead>
|
||||
<tbody><tr v-for="item in activeRows" :key="item.id"><td><strong>{{ item.subscriptionKey }}</strong><small>{{ item.id }}</small></td><td><span :class="['commercial-status-pill', item.status]">{{ statusLabel(item.status) }}</span><small v-if="latestStatusReason(item)">{{ latestStatusReason(item) }}</small></td><td>{{ formatDateTime(item.currentPeriodStart) }}<small>至 {{ formatDateTime(item.currentPeriodEnd) }}</small></td><td class="numeric">{{ item.seats }}</td><td>v{{ item.version }}</td><td><div class="commercial-history-actions"><button v-if="canRestore(item)" type="button" @click="manageSubscription('activateSubscription', item)">恢复</button><button v-if="canTransition(item)" type="button" @click="manageSubscription('transitionSubscription', item)">变更状态</button><span v-if="!canRestore(item) && !canTransition(item)">终态</span></div></td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<table v-else-if="activeTab === 'entitlements'">
|
||||
<thead><tr><th scope="col">权益</th><th scope="col">指标</th><th scope="col">类型</th><th scope="col">状态</th><th scope="col">版本</th><th scope="col">创建时间</th></tr></thead>
|
||||
<tbody><tr v-for="item in activeRows" :key="item.id"><td><strong>{{ item.entitlementKey }}</strong><small>{{ item.id }}</small></td><td>{{ item.metricKey }}<small>{{ item.unit }}</small></td><td>{{ item.entitlementType }}</td><td><span :class="['commercial-status-pill', item.status]">{{ statusLabel(item.status) }}</span></td><td>v{{ item.version }}</td><td>{{ formatDateTime(item.createdAt) }}</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<table v-else-if="activeTab === 'usageEvents'">
|
||||
<thead><tr><th scope="col">事件</th><th scope="col">指标</th><th scope="col">数量</th><th scope="col">来源</th><th scope="col">幂等键</th><th scope="col">发生时间</th></tr></thead>
|
||||
<tbody><tr v-for="item in activeRows" :key="item.id"><td><strong>{{ item.eventType }}</strong><small>{{ item.id }}</small></td><td>{{ item.metricKey }}</td><td class="numeric">{{ item.quantity }} {{ item.unit }}</td><td>{{ item.sourceSystem }}</td><td class="commercial-mono">{{ item.idempotencyKey }}</td><td>{{ formatDateTime(item.occurredAt) }}</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<table v-else>
|
||||
<thead><tr><th scope="col">成本</th><th scope="col">数量</th><th scope="col">报告金额</th><th scope="col">提供商</th><th scope="col">幂等键</th><th scope="col">发生时间</th></tr></thead>
|
||||
<tbody><tr v-for="item in activeRows" :key="item.id"><td><strong>{{ item.costCategory }}</strong><small>{{ item.eventType }} · {{ item.id }}</small></td><td class="numeric">{{ item.quantity }} {{ item.unit }}</td><td class="numeric">{{ formatCurrencyAmount(item.reportingAmount, item.reportingCurrency) }}<small>原币 {{ item.originalCurrency }} · 汇率 {{ item.fxRate }}</small></td><td>{{ item.provider || '—' }}</td><td class="commercial-mono">{{ item.idempotencyKey }}</td><td>{{ formatDateTime(item.occurredAt) }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { formatCurrencyAmount, statusLabel } from './commercialWorkspaceModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
platformAdmin: { type: Boolean, default: false },
|
||||
history: { type: Object, required: true },
|
||||
loaded: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: Object, default: null }
|
||||
})
|
||||
const emit = defineEmits(['load', 'manage-subscription'])
|
||||
const tabs = [
|
||||
{ key: 'plans', label: '套餐版本' },
|
||||
{ key: 'subscriptions', label: '订阅历史' },
|
||||
{ key: 'entitlements', label: '权益版本' },
|
||||
{ key: 'usageEvents', label: '用量事件' },
|
||||
{ key: 'costEvents', label: '成本事件' }
|
||||
]
|
||||
const activeTab = ref('plans')
|
||||
const activeRows = computed(() => Array.isArray(props.history[activeTab.value]) ? props.history[activeTab.value] : [])
|
||||
const activeTabLabel = computed(() => tabs.find((item) => item.key === activeTab.value)?.label || '当前分类')
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = new Date(value)
|
||||
return Number.isFinite(date.getTime()) ? date.toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
}
|
||||
|
||||
function latestStatusReason(item) {
|
||||
const rows = item?.metadataJson?.statusHistory
|
||||
return Array.isArray(rows) && rows.length ? String(rows.at(-1)?.reason || '') : ''
|
||||
}
|
||||
|
||||
function canRestore(item) {
|
||||
return ['past_due', 'suspended'].includes(item.status)
|
||||
}
|
||||
|
||||
function canTransition(item) {
|
||||
return !['canceled', 'expired'].includes(item.status)
|
||||
}
|
||||
|
||||
function manageSubscription(kind, item) {
|
||||
emit('manage-subscription', {
|
||||
kind,
|
||||
form: { resourceId: item.id, expectedVersion: item.version, currentStatus: item.status }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
56
web/src/components/commercial/CommercialMetricCard.vue
Normal file
56
web/src/components/commercial/CommercialMetricCard.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<article :class="['commercial-metric-card', metric.status]">
|
||||
<header>
|
||||
<div>
|
||||
<span>{{ metric.category === 'contributionMargin' ? '合同代理口径' : '事实口径' }}</span>
|
||||
<h4>{{ metric.label }}</h4>
|
||||
</div>
|
||||
<em>{{ metric.statusLabel }}</em>
|
||||
</header>
|
||||
|
||||
<div v-if="metric.values.length" class="commercial-metric-values">
|
||||
<div v-for="item in metric.values" :key="item.currency">
|
||||
<strong>{{ item.displayValue }}</strong>
|
||||
<small>{{ item.currency }} · {{ basisLabel(item.basis) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="metric.ratios.length" class="commercial-metric-values ratio">
|
||||
<div v-for="item in metric.ratios" :key="item.currency">
|
||||
<strong>{{ item.displayValue }}</strong>
|
||||
<small>{{ item.currency }} · 净收益 {{ item.numeratorLabel }} / 收费 {{ item.denominatorLabel }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<strong v-else class="commercial-unavailable-value">不可用</strong>
|
||||
|
||||
<p>{{ metric.reason }}</p>
|
||||
<p v-if="metric.multiCurrency" class="commercial-currency-guard">
|
||||
<i class="mdi mdi-shield-check-outline" aria-hidden="true"></i>
|
||||
多币种按币种独立展示,未执行隐式汇率换算或合计。
|
||||
</p>
|
||||
<details v-if="metric.requiredInputs.length || metric.notes.length">
|
||||
<summary>{{ metric.status === 'unavailable' ? '查看缺失证据' : '查看口径说明' }}</summary>
|
||||
<ul>
|
||||
<li v-for="item in metric.requiredInputs" :key="`required-${item}`">待补:{{ item }}</li>
|
||||
<li v-for="item in metric.notes" :key="`note-${item}`">{{ item }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
metric: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const BASIS_LABELS = {
|
||||
verified_savings_ledger: '财务确认 Savings Ledger',
|
||||
contract_charge_proxy: '合同收费代理值',
|
||||
internal_cost_ledger: '内部成本事件台账',
|
||||
contract_contribution_proxy: '合同贡献代理值',
|
||||
customer_approved_labor_baseline: '客户批准工时基线'
|
||||
}
|
||||
|
||||
function basisLabel(value) {
|
||||
return BASIS_LABELS[value] || String(value || '后端事实口径')
|
||||
}
|
||||
</script>
|
||||
245
web/src/components/commercial/CommercialMutationDialog.vue
Normal file
245
web/src/components/commercial/CommercialMutationDialog.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="commercial-dialog-mask"
|
||||
role="presentation"
|
||||
@click.self="closeDialog"
|
||||
@keydown.esc="closeDialog"
|
||||
@keydown.tab="keepFocusInside"
|
||||
>
|
||||
<section ref="dialogCard" class="commercial-dialog" role="alertdialog" aria-modal="true" :aria-labelledby="titleId">
|
||||
<header>
|
||||
<div>
|
||||
<span>{{ meta?.family === 'metering' ? 'Append-only ledger' : 'Versioned configuration' }}</span>
|
||||
<h3 :id="titleId">{{ meta?.label || '商业管理操作' }}</h3>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
<button type="button" class="commercial-icon-button" aria-label="关闭对话框" :disabled="busy" @click="closeDialog">
|
||||
<i class="mdi mdi-close" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form v-if="step === 'edit'" class="commercial-dialog-form" @submit.prevent="reviewMutation">
|
||||
<div v-if="kind === 'createPlan'" class="commercial-form-grid">
|
||||
<label><span>套餐编码 *</span><input ref="firstInput" v-model.trim="draft.planCode" maxlength="80" autocomplete="off" required /></label>
|
||||
<label><span>套餐名称 *</span><input v-model.trim="draft.name" maxlength="160" autocomplete="off" required /></label>
|
||||
<label><span>计价模式 *</span><select v-model="draft.pricingModel"><option value="subscription">订阅制</option><option value="usage">按量</option><option value="hybrid">订阅 + 按量</option><option value="pilot">试点</option><option value="custom">定制</option></select></label>
|
||||
<label><span>计费周期 *</span><select v-model="draft.billingInterval"><option value="monthly">月</option><option value="quarterly">季度</option><option value="annual">年</option><option value="contract">合同周期</option></select></label>
|
||||
<label><span>币种 *</span><input v-model.trim="draft.currency" maxlength="3" autocapitalize="characters" required /><small>ISO 4217 三位币种,不执行隐式换算。</small></label>
|
||||
<label><span>基础费用 *</span><input v-model="draft.baseFee" type="number" min="0" step="0.0001" required /></label>
|
||||
<label><span>包含席位 *</span><input v-model="draft.includedSeats" type="number" min="0" step="1" required /></label>
|
||||
<label class="commercial-check-label"><input v-model="draft.overageEnabled" type="checkbox" /><span>允许超额计费</span></label>
|
||||
<label><span>生效时间 *</span><input v-model="draft.effectiveFrom" type="datetime-local" required /></label>
|
||||
<label><span>失效时间</span><input v-model="draft.effectiveTo" type="datetime-local" /></label>
|
||||
<label class="commercial-form-wide"><span>版本化合同条款 JSON</span><textarea v-model="draft.contractTermsJson" rows="4" spellcheck="false"></textarea><small>客户工时价值仅接受包含批准人和核验时间的完整基线。</small></label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind === 'createSubscription'" class="commercial-form-grid">
|
||||
<label><span>订阅键 *</span><input ref="firstInput" v-model.trim="draft.subscriptionKey" maxlength="120" autocomplete="off" required /></label>
|
||||
<label><span>套餐 ID *</span><input v-model.trim="draft.planId" maxlength="36" required /></label>
|
||||
<label><span>初始状态 *</span><select v-model="draft.status"><option value="active">生效</option><option value="trialing">试用</option></select></label>
|
||||
<label><span>席位数 *</span><input v-model="draft.seats" type="number" min="1" step="1" required /></label>
|
||||
<label><span>订阅开始 *</span><input v-model="draft.startsAt" type="datetime-local" required /></label>
|
||||
<label><span>订阅结束</span><input v-model="draft.endsAt" type="datetime-local" /></label>
|
||||
<label><span>当前周期开始 *</span><input v-model="draft.currentPeriodStart" type="datetime-local" required /></label>
|
||||
<label><span>当前周期结束 *</span><input v-model="draft.currentPeriodEnd" type="datetime-local" required /></label>
|
||||
<label class="commercial-check-label"><input v-model="draft.autoRenew" type="checkbox" /><span>自动续费</span></label>
|
||||
<label><span>外部提供商</span><input v-model.trim="draft.externalProvider" maxlength="60" /></label>
|
||||
<label><span>外部订阅编号</span><input v-model.trim="draft.externalSubscriptionId" maxlength="160" /></label>
|
||||
<label class="commercial-form-wide"><span>订阅元数据 JSON</span><textarea v-model="draft.metadataJson" rows="3" spellcheck="false"></textarea></label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind === 'upsertEntitlement'" class="commercial-form-grid">
|
||||
<label><span>订阅 ID *</span><input ref="firstInput" v-model.trim="draft.subscriptionId" maxlength="36" required /></label>
|
||||
<label><span>权益键 *</span><input v-model.trim="draft.entitlementKey" maxlength="120" required /></label>
|
||||
<label><span>指标键 *</span><input v-model.trim="draft.metricKey" maxlength="120" required /></label>
|
||||
<label><span>权益类型 *</span><select v-model="draft.entitlementType"><option value="metered">计量权益</option><option value="feature">功能开关</option><option value="unlimited">不限量</option></select></label>
|
||||
<label><span>单位 *</span><input v-model.trim="draft.unit" maxlength="40" required /></label>
|
||||
<label><span>包含量</span><input v-model="draft.includedQuantity" type="number" min="0" step="0.000001" :disabled="draft.entitlementType === 'unlimited'" /></label>
|
||||
<label><span>硬配额</span><input v-model="draft.hardLimitQuantity" type="number" min="0" step="0.000001" :disabled="draft.entitlementType === 'unlimited'" /></label>
|
||||
<label><span>重置周期 *</span><select v-model="draft.resetInterval"><option value="none">不重置</option><option value="monthly">月</option><option value="quarterly">季度</option><option value="annual">年</option><option value="contract">合同周期</option></select></label>
|
||||
<label><span>超额策略 *</span><select v-model="draft.overagePolicy"><option value="block">阻断</option><option value="alert">告警</option><option value="allow">允许</option></select></label>
|
||||
<label><span>状态 *</span><select v-model="draft.status"><option value="active">生效</option><option value="suspended">暂停</option><option value="expired">过期</option></select></label>
|
||||
<label><span>生效时间 *</span><input v-model="draft.effectiveFrom" type="datetime-local" required /></label>
|
||||
<label><span>失效时间</span><input v-model="draft.effectiveTo" type="datetime-local" /></label>
|
||||
<label class="commercial-form-wide"><span>权益配置 JSON</span><textarea v-model="draft.configJson" rows="4" spellcheck="false"></textarea><small>按量计价需显式提供 billable_unit_price、billing_currency 和 billing_mode。</small></label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind?.startsWith('activate')" class="commercial-form-grid">
|
||||
<label><span>资源 ID *</span><input ref="firstInput" v-model.trim="draft.resourceId" maxlength="36" required /></label>
|
||||
<label><span>期望版本 *</span><input v-model="draft.expectedVersion" type="number" min="1" step="1" required /></label>
|
||||
<label class="commercial-form-wide"><span>激活原因 *</span><textarea v-model.trim="draft.reason" rows="3" maxlength="500" required></textarea><small>原因与请求编号会进入不可变商业审计。</small></label>
|
||||
<p class="commercial-form-wide commercial-form-note"><i class="mdi mdi-source-branch" aria-hidden="true"></i>乐观锁会阻止使用旧版本覆盖其他管理员的最新变更。</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind === 'transitionSubscription'" class="commercial-form-grid">
|
||||
<label><span>订阅 ID *</span><input ref="firstInput" v-model.trim="draft.resourceId" maxlength="36" required /></label>
|
||||
<label><span>期望版本 *</span><input v-model="draft.expectedVersion" type="number" min="1" step="1" required /></label>
|
||||
<label><span>目标状态 *</span><select v-model="draft.targetStatus" required><option v-for="option in transitionOptions" :key="option.value" :value="option.value">{{ option.label }}</option></select></label>
|
||||
<label class="commercial-form-wide"><span>变更原因 *</span><textarea v-model.trim="draft.reason" rows="3" maxlength="500" required></textarea><small>原因会进入订阅状态历史;取消和过期后只能创建新订阅恢复服务。</small></label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind === 'recordUsage'" class="commercial-form-grid">
|
||||
<label><span>订阅 ID *</span><input ref="firstInput" v-model.trim="draft.subscriptionId" maxlength="36" required /></label>
|
||||
<label><span>权益 ID *</span><input v-model.trim="draft.entitlementId" maxlength="36" required /></label>
|
||||
<label><span>事件类型 *</span><select v-model="draft.eventType"><option value="usage">用量</option><option value="credit">抵扣</option><option value="adjustment">调整</option><option value="reversal">冲回</option></select></label>
|
||||
<label><span>数量 *</span><input v-model="draft.quantity" type="number" step="0.000001" required /></label>
|
||||
<label><span>发生时间 *</span><input v-model="draft.occurredAt" type="datetime-local" required /></label>
|
||||
<label><span>来源系统 *</span><input v-model.trim="draft.sourceSystem" maxlength="80" required /></label>
|
||||
<label class="commercial-form-wide"><span>幂等键 *</span><input v-model.trim="draft.idempotencyKey" maxlength="160" required /><small>重试必须复用同一幂等键;同键不同载荷会被服务器拒绝。</small></label>
|
||||
<label v-if="draft.eventType === 'reversal'" class="commercial-form-wide"><span>被冲回事件 ID *</span><input v-model.trim="draft.reversalOfEventId" maxlength="36" required /></label>
|
||||
<label><span>主体类型</span><input v-model.trim="draft.subjectType" maxlength="60" /></label>
|
||||
<label><span>主体 ID</span><input v-model.trim="draft.subjectId" maxlength="160" /></label>
|
||||
<label><span>关联 ID</span><input v-model.trim="draft.correlationId" maxlength="120" /></label>
|
||||
<label class="commercial-form-wide"><span>用量元数据 JSON</span><textarea v-model="draft.metadataJson" rows="3" spellcheck="false"></textarea></label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind === 'recordCost'" class="commercial-form-grid">
|
||||
<label><span>订阅 ID</span><input ref="firstInput" v-model.trim="draft.subscriptionId" maxlength="36" /></label>
|
||||
<label><span>关联用量事件 ID</span><input v-model.trim="draft.usageEventId" maxlength="36" /></label>
|
||||
<label><span>事件类型 *</span><select v-model="draft.eventType"><option value="incurred">发生</option><option value="credit">抵扣</option><option value="adjustment">调整</option><option value="reversal">冲回</option></select></label>
|
||||
<label><span>成本类别 *</span><select v-model="draft.costCategory"><option value="ai_inference">AI 推理</option><option value="ocr">OCR</option><option value="storage">存储</option><option value="connector">连接器</option><option value="support">支持</option><option value="implementation">实施</option><option value="infrastructure">基础设施</option><option value="payment">支付</option><option value="other">其他</option></select></label>
|
||||
<label><span>数量 *</span><input v-model="draft.quantity" type="number" min="0.000001" step="0.000001" required /></label>
|
||||
<label><span>单位 *</span><input v-model.trim="draft.unit" maxlength="40" required /></label>
|
||||
<label><span>单位成本 *</span><input v-model="draft.unitCost" type="number" min="0" step="0.00000001" required /></label>
|
||||
<label><span>原始币种 *</span><input v-model.trim="draft.originalCurrency" maxlength="3" required /></label>
|
||||
<label><span>报告币种 *</span><input v-model.trim="draft.reportingCurrency" maxlength="3" required /></label>
|
||||
<label><span>显式汇率 *</span><input v-model="draft.fxRate" type="number" min="0.00000001" step="0.00000001" required /></label>
|
||||
<label><span>归集键 *</span><input v-model.trim="draft.allocationKey" maxlength="160" required /></label>
|
||||
<label><span>发生时间 *</span><input v-model="draft.occurredAt" type="datetime-local" required /></label>
|
||||
<label><span>来源系统 *</span><input v-model.trim="draft.sourceSystem" maxlength="80" required /></label>
|
||||
<label><span>提供商</span><input v-model.trim="draft.provider" maxlength="120" /></label>
|
||||
<label><span>SKU</span><input v-model.trim="draft.sku" maxlength="120" /></label>
|
||||
<label><span>模型</span><input v-model.trim="draft.modelName" maxlength="120" /></label>
|
||||
<label class="commercial-form-wide"><span>幂等键 *</span><input v-model.trim="draft.idempotencyKey" maxlength="160" required /></label>
|
||||
<label v-if="draft.eventType === 'reversal'" class="commercial-form-wide"><span>被冲回成本事件 ID *</span><input v-model.trim="draft.reversalOfCostEventId" maxlength="36" required /></label>
|
||||
<label class="commercial-form-wide"><span>成本元数据 JSON</span><textarea v-model="draft.metadataJson" rows="3" spellcheck="false"></textarea></label>
|
||||
</div>
|
||||
|
||||
<p v-if="localError" class="commercial-form-error" role="alert">{{ localError }}</p>
|
||||
<footer>
|
||||
<button type="button" class="commercial-button ghost" @click="closeDialog">取消</button>
|
||||
<button type="submit" class="commercial-button primary">核对并继续</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<div v-else class="commercial-confirmation">
|
||||
<div class="commercial-confirmation-icon"><i class="mdi mdi-shield-check-outline" aria-hidden="true"></i></div>
|
||||
<strong>{{ meta?.confirm }}</strong>
|
||||
<p>请核对以下服务器写入内容。本操作不会在客户端补造缺失事实。</p>
|
||||
<ul><li v-for="item in confirmationRows" :key="item">{{ item }}</li></ul>
|
||||
<p v-if="localError" class="commercial-form-error" role="alert">{{ localError }}</p>
|
||||
<footer>
|
||||
<button type="button" class="commercial-button ghost" :disabled="busy" @click="step = 'edit'">返回修改</button>
|
||||
<button type="button" class="commercial-button primary" :disabled="busy" @click="submitMutation">
|
||||
<i :class="busy ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-check-bold'" aria-hidden="true"></i>
|
||||
{{ busy ? '正在写入' : '确认写入' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
buildCommercialConfirmationRows,
|
||||
commercialActionMeta,
|
||||
createCommercialForm,
|
||||
resolveSubscriptionTransitionTargets
|
||||
} from './commercialFormModel.js'
|
||||
import { buildCommercialMutation } from './commercialWorkspaceModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
kind: { type: String, default: '' },
|
||||
account: { type: Object, default: null },
|
||||
preset: { type: Object, default: () => ({}) },
|
||||
busy: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['close', 'submit'])
|
||||
const instance = getCurrentInstance()
|
||||
const draft = reactive({})
|
||||
const step = ref('edit')
|
||||
const localError = ref('')
|
||||
const normalizedPayload = ref(null)
|
||||
const firstInput = ref(null)
|
||||
const dialogCard = ref(null)
|
||||
let previousFocus = null
|
||||
|
||||
const meta = computed(() => commercialActionMeta(props.kind))
|
||||
const titleId = computed(() => `commercial-mutation-title-${instance?.uid || 'dialog'}`)
|
||||
const confirmationRows = computed(() => buildCommercialConfirmationRows(props.kind, normalizedPayload.value || {}))
|
||||
const transitionOptions = computed(() => {
|
||||
const labels = { past_due: '标记逾期', suspended: '暂停', canceled: '取消(终态)', expired: '过期(终态)' }
|
||||
return resolveSubscriptionTransitionTargets(draft.currentStatus)
|
||||
.map((value) => ({ value, label: labels[value] }))
|
||||
})
|
||||
const description = computed(() => {
|
||||
if (meta.value?.family === 'metering') return '事件写入后不可编辑,纠错必须通过追加调整或冲回事件完成。'
|
||||
if (meta.value?.family === 'activation') return '激活使用乐观锁版本校验,避免并发覆盖。'
|
||||
return '配置采用版本化快照,历史计费事实不会被静默改写。'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.open, props.kind, props.preset],
|
||||
([open]) => {
|
||||
if (!open) {
|
||||
if (previousFocus && typeof previousFocus.focus === 'function') {
|
||||
void nextTick(() => previousFocus?.focus?.())
|
||||
}
|
||||
previousFocus = null
|
||||
return
|
||||
}
|
||||
previousFocus = typeof document !== 'undefined' ? document.activeElement : null
|
||||
Object.keys(draft).forEach((key) => delete draft[key])
|
||||
Object.assign(draft, createCommercialForm(props.kind, props.account), props.preset)
|
||||
step.value = 'edit'
|
||||
localError.value = ''
|
||||
normalizedPayload.value = null
|
||||
void nextTick(() => firstInput.value?.focus?.())
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function closeDialog() {
|
||||
if (!props.busy) emit('close')
|
||||
}
|
||||
|
||||
function keepFocusInside(event) {
|
||||
const controls = Array.from(dialogCard.value?.querySelectorAll?.(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||
) || [])
|
||||
if (!controls.length) return
|
||||
const first = controls[0]
|
||||
const last = controls.at(-1)
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function reviewMutation() {
|
||||
localError.value = ''
|
||||
try {
|
||||
normalizedPayload.value = buildCommercialMutation(props.kind, draft, props.account)
|
||||
step.value = 'confirm'
|
||||
} catch (error) {
|
||||
localError.value = error.message || '请检查输入后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
function submitMutation() {
|
||||
localError.value = ''
|
||||
emit('submit', { kind: props.kind, form: { ...draft } }, (error) => {
|
||||
localError.value = error?.message || ''
|
||||
})
|
||||
}
|
||||
</script>
|
||||
278
web/src/components/commercial/CommercialPricingScenarioPanel.vue
Normal file
278
web/src/components/commercial/CommercialPricingScenarioPanel.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<section
|
||||
class="commercial-section commercial-pricing-section"
|
||||
aria-labelledby="commercial-pricing-title"
|
||||
:aria-busy="loading ? 'true' : 'false'"
|
||||
>
|
||||
<header class="commercial-section-head">
|
||||
<div>
|
||||
<span>Pricing corridor</span>
|
||||
<h3 id="commercial-pricing-title">可持续定价场景</h3>
|
||||
<p>用真实内部成本确定价格下限,用财务确认现金节省确定价值上限;结果只用于谈判,不会自动写入套餐。</p>
|
||||
</div>
|
||||
<span :class="['commercial-pricing-access', platformAdmin ? 'allowed' : 'denied']">
|
||||
<i :class="platformAdmin ? 'mdi mdi-calculator-variant-outline' : 'mdi mdi-shield-lock-outline'" aria-hidden="true"></i>
|
||||
{{ platformAdmin ? '平台管理员可计算' : '无计算权限' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="!platformAdmin" class="commercial-state-card permission" role="status">
|
||||
<i class="mdi mdi-shield-account-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>定价场景仅向平台管理员开放</strong>
|
||||
<span>该结果包含平台内部成本和单位经济性,普通租户账号不会收到这些数据。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="loading" class="commercial-operation-feedback pricing-progress" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
正在按币种核对成本与已确认价值;已有结果会保留到本次计算成功。
|
||||
</div>
|
||||
<div v-if="error" ref="errorSummary" class="commercial-operation-feedback danger" role="alert" tabindex="-1">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
|
||||
<span>
|
||||
<strong>定价场景计算失败</strong>
|
||||
{{ view ? '当前继续显示上一次成功结果。' : '' }}{{ error.message || '请核对输入和权限后重试。' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form v-if="step === 'edit'" class="commercial-pricing-form" @submit.prevent="reviewPricing">
|
||||
<fieldset :disabled="loading">
|
||||
<legend>计算参数</legend>
|
||||
<div class="commercial-pricing-fields">
|
||||
<label for="commercial-pricing-start">
|
||||
<span>分析开始日期 *</span>
|
||||
<input id="commercial-pricing-start" v-model="draft.start" type="date" required />
|
||||
</label>
|
||||
<label for="commercial-pricing-end">
|
||||
<span>分析结束日期 *</span>
|
||||
<input id="commercial-pricing-end" v-model="draft.end" type="date" required />
|
||||
</label>
|
||||
<label for="commercial-pricing-as-of">
|
||||
<span>证据截止时间(as_of)*</span>
|
||||
<input id="commercial-pricing-as-of" v-model="draft.asOf" type="datetime-local" required />
|
||||
<small>只使用此时点前可追溯、已确认的真实事实。</small>
|
||||
</label>
|
||||
<label for="commercial-pricing-margin">
|
||||
<span>目标贡献毛利率 *</span>
|
||||
<div class="commercial-percent-input">
|
||||
<input
|
||||
id="commercial-pricing-margin"
|
||||
v-model.trim="draft.targetContributionMarginPercent"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
max="94.9999"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
<span aria-hidden="true">%</span>
|
||||
</div>
|
||||
<small>必须大于等于 0 且小于 95%。</small>
|
||||
</label>
|
||||
<label for="commercial-pricing-share">
|
||||
<span>最大价值分享比例 *</span>
|
||||
<div class="commercial-percent-input">
|
||||
<input
|
||||
id="commercial-pricing-share"
|
||||
v-model.trim="draft.maxVerifiedSavingsSharePercent"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0.000001"
|
||||
max="100"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
<span aria-hidden="true">%</span>
|
||||
</div>
|
||||
<small>只作用于财务确认现金节省,不包含预计机会或风险暴露。</small>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p v-if="localError" ref="localErrorSummary" class="commercial-form-error" role="alert" tabindex="-1">
|
||||
{{ localError }}
|
||||
</p>
|
||||
<footer class="commercial-pricing-form-actions">
|
||||
<span>单次分析最长 731 天;所有金额按原币种分别计算。</span>
|
||||
<button type="submit" class="commercial-button primary" :disabled="loading">
|
||||
<i class="mdi mdi-clipboard-check-outline" aria-hidden="true"></i>核对计算条件
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<section
|
||||
v-else
|
||||
ref="reviewSection"
|
||||
class="commercial-pricing-confirmation"
|
||||
role="region"
|
||||
aria-labelledby="commercial-pricing-confirm-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header>
|
||||
<i class="mdi mdi-shield-check-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong id="commercial-pricing-confirm-title">确认计算条件</strong>
|
||||
<span>这次操作只生成定价建议,不创建、激活或修改任何套餐。</span>
|
||||
</div>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>分析时间窗</dt><dd>{{ formatWindow(reviewedPayload) }}</dd></div>
|
||||
<div><dt>证据截止</dt><dd>{{ formatDateTime(reviewedPayload?.asOf) }}</dd></div>
|
||||
<div><dt>目标贡献毛利率</dt><dd>{{ formatRatio(reviewedPayload?.targetContributionMarginRate) }}</dd></div>
|
||||
<div><dt>最大价值分享</dt><dd>{{ formatRatio(reviewedPayload?.maxVerifiedSavingsShare) }}</dd></div>
|
||||
</dl>
|
||||
<p v-if="localError" ref="localErrorSummary" class="commercial-form-error" role="alert" tabindex="-1">
|
||||
{{ localError }}
|
||||
</p>
|
||||
<footer>
|
||||
<button type="button" class="commercial-button ghost" :disabled="loading" @click="step = 'edit'">返回修改</button>
|
||||
<button type="button" class="commercial-button primary" :disabled="loading" @click="confirmPricing">
|
||||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-calculator-variant-outline'" aria-hidden="true"></i>
|
||||
{{ loading ? '正在计算' : '确认并计算' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<div v-if="!view && !loading" class="commercial-pricing-empty" role="status">
|
||||
<i class="mdi mdi-chart-box-plus-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>尚未生成定价场景</strong>
|
||||
<span>没有结果不代表成本或客户价值为 0;请先核对参数并发起计算。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-else-if="view" class="commercial-pricing-result" aria-labelledby="commercial-pricing-result-title" aria-live="polite">
|
||||
<header>
|
||||
<div>
|
||||
<span>Scenario result</span>
|
||||
<h4 id="commercial-pricing-result-title">定价走廊结果</h4>
|
||||
<p>{{ formatWindow(view) }} · 证据截止 {{ formatDateTime(view.asOf) }}</p>
|
||||
</div>
|
||||
<span :class="['commercial-status-pill', view.evidenceStatus]">证据{{ view.evidenceStatusLabel }}</span>
|
||||
</header>
|
||||
|
||||
<div class="commercial-pricing-summary">
|
||||
<article>
|
||||
<span>推荐商业模式</span>
|
||||
<strong>{{ view.recommendedModelLabel }}</strong>
|
||||
<small>系统只给出谈判边界,不会自动写入套餐。</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>计算假设</span>
|
||||
<strong>{{ view.targetMarginLabel }} 毛利 / {{ view.maxValueShareLabel }} 价值分享</strong>
|
||||
<small>{{ view.multiCurrency ? '检测到多个币种,以下结果严格分币种展示。' : '当前结果按单一币种事实展示。' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="!view.rows.length" class="commercial-state-card warning" role="status">
|
||||
<i class="mdi mdi-database-off-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>没有可建立币种场景的事实</strong>
|
||||
<span>内部成本和财务确认现金节省均不可用;这不是零成本、零价值或免费建议。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="commercial-pricing-grid">
|
||||
<article v-for="row in view.rows" :key="row.key" :class="['commercial-pricing-card', row.status]">
|
||||
<header>
|
||||
<div><span>币种</span><strong>{{ row.currency }}</strong></div>
|
||||
<em>{{ row.statusLabel }}</em>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>内部成本</dt><dd>{{ row.internalCostLabel }}</dd></div>
|
||||
<div><dt>财务确认节省</dt><dd>{{ row.verifiedCashSavingsLabel }}</dd></div>
|
||||
<div class="pricing-bound"><dt>可持续收费下限</dt><dd>{{ row.minimumSustainableChargeLabel }}</dd></div>
|
||||
<div class="pricing-bound"><dt>价值对齐收费上限</dt><dd>{{ row.maximumValueAlignedChargeLabel }}</dd></div>
|
||||
<div><dt>成功费封顶</dt><dd>{{ row.maximumSuccessFeeLabel }}</dd></div>
|
||||
<div><dt>下限对应客户 ROI</dt><dd>{{ row.customerRoiLabel }}</dd></div>
|
||||
<div><dt>上限对应贡献毛利率</dt><dd>{{ row.contributionMarginLabel }}</dd></div>
|
||||
</dl>
|
||||
<p>{{ row.reason }}</p>
|
||||
<div v-if="row.missingInputs.length" class="commercial-pricing-missing" role="note">
|
||||
<strong>缺失输入</strong>
|
||||
<ul><li v-for="item in row.missingInputs" :key="item">{{ item }}</li></ul>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<details v-if="view.notes.length" class="commercial-pricing-notes">
|
||||
<summary>查看证据口径与注意事项</summary>
|
||||
<ul><li v-for="note in view.notes" :key="note">{{ note }}</li></ul>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
buildPricingScenarioPayload,
|
||||
createDefaultPricingScenarioForm,
|
||||
formatRatio
|
||||
} from './commercialWorkspaceModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
tenantId: { type: String, default: '' },
|
||||
platformAdmin: { type: Boolean, default: false },
|
||||
range: { type: Object, required: true },
|
||||
view: { type: Object, default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: Object, default: null },
|
||||
execute: { type: Function, required: true }
|
||||
})
|
||||
|
||||
const draft = reactive(createDefaultPricingScenarioForm(props.range))
|
||||
const step = ref('edit')
|
||||
const localError = ref('')
|
||||
const reviewedPayload = ref(null)
|
||||
const reviewSection = ref(null)
|
||||
const localErrorSummary = ref(null)
|
||||
const errorSummary = ref(null)
|
||||
|
||||
watch(() => props.tenantId, () => {
|
||||
Object.assign(draft, createDefaultPricingScenarioForm(props.range))
|
||||
step.value = 'edit'
|
||||
localError.value = ''
|
||||
reviewedPayload.value = null
|
||||
})
|
||||
|
||||
watch(() => props.error, (error) => {
|
||||
if (error) void nextTick(() => errorSummary.value?.focus?.())
|
||||
})
|
||||
|
||||
function reviewPricing() {
|
||||
localError.value = ''
|
||||
try {
|
||||
reviewedPayload.value = buildPricingScenarioPayload(draft)
|
||||
step.value = 'confirm'
|
||||
void nextTick(() => reviewSection.value?.focus?.())
|
||||
} catch (error) {
|
||||
localError.value = error.message || '请检查定价场景输入。'
|
||||
void nextTick(() => localErrorSummary.value?.focus?.())
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPricing() {
|
||||
localError.value = ''
|
||||
try {
|
||||
await props.execute({ ...draft })
|
||||
step.value = 'edit'
|
||||
} catch (error) {
|
||||
localError.value = error.message || '定价场景计算失败,请修正后重试。'
|
||||
void nextTick(() => localErrorSummary.value?.focus?.())
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = new Date(value)
|
||||
return Number.isFinite(date.getTime())
|
||||
? date.toLocaleString('zh-CN', { hour12: false })
|
||||
: '不可用'
|
||||
}
|
||||
|
||||
function formatWindow(value) {
|
||||
return `${formatDateTime(value?.start)} 至 ${formatDateTime(value?.end)}`
|
||||
}
|
||||
</script>
|
||||
102
web/src/components/commercial/CommercialValueProofPanel.vue
Normal file
102
web/src/components/commercial/CommercialValueProofPanel.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<section class="commercial-section commercial-value-section" aria-labelledby="commercial-value-title">
|
||||
<header class="commercial-section-head">
|
||||
<div>
|
||||
<span>Value proof</span>
|
||||
<h3 id="commercial-value-title">客户价值与平台经营分账</h3>
|
||||
<p>客户 ROI、客户节省、平台成本和贡献毛利分别展示;不同币种不会混算。</p>
|
||||
</div>
|
||||
<form class="commercial-range-form" @submit.prevent="$emit('reload')">
|
||||
<label><span>开始日期</span><input type="date" :value="range.start" @input="updateRange('start', $event.target.value)" /></label>
|
||||
<label><span>结束日期</span><input type="date" :value="range.end" @input="updateRange('end', $event.target.value)" /></label>
|
||||
<button type="submit" class="commercial-button secondary" :disabled="loading || !platformAdmin">
|
||||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-chart-timeline-variant'" aria-hidden="true"></i>
|
||||
{{ loading ? '计算中' : '重新计算' }}
|
||||
</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div v-if="!platformAdmin" class="commercial-state-card permission" role="status">
|
||||
<i class="mdi mdi-shield-account-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>商业经营分账仅向平台管理员开放</strong>
|
||||
<span>当前账号仍可查看自身套餐和权益;平台内部成本、贡献毛利及合同收费代理值不会越权下发。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading && !analytics" class="commercial-state-card" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||
<div><strong>正在核对双账事实</strong><span>等待 Savings Ledger、合同收费代理值和内部成本事件完成分账。</span></div>
|
||||
</div>
|
||||
<div v-else-if="error && !analytics" class="commercial-state-card danger" role="alert">
|
||||
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
|
||||
<div><strong>商业价值分析加载失败</strong><span>{{ error.message || '请检查网络后重试。' }}</span></div>
|
||||
<button type="button" class="commercial-button secondary" @click="$emit('reload')">重新加载</button>
|
||||
</div>
|
||||
<div v-else-if="!analytics" class="commercial-state-card warning" role="status">
|
||||
<i class="mdi mdi-database-search-outline" aria-hidden="true"></i>
|
||||
<div><strong>尚无可展示的商业分析</strong><span>这不是零成本或零价值;系统尚未取得能够支持判断的真实事实。</span></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="error" class="commercial-inline-warning danger" role="alert">
|
||||
<i class="mdi mdi-cloud-alert-outline" aria-hidden="true"></i>
|
||||
最新刷新失败,当前保留上一次成功结果:{{ error.message }}
|
||||
</div>
|
||||
<div :class="['commercial-quality-banner', analytics.dataQualityStatus]" role="status">
|
||||
<i class="mdi mdi-database-check-outline" aria-hidden="true"></i>
|
||||
<div>
|
||||
<strong>数据质量:{{ statusLabel(analytics.dataQualityStatus) }}</strong>
|
||||
<span>{{ analytics.dataQualityIssues?.length ? analytics.dataQualityIssues.join(';') : '后端未报告额外数据缺口。' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="commercial-ledger customer" aria-labelledby="commercial-customer-ledger-title">
|
||||
<header>
|
||||
<div>
|
||||
<span>Customer value ledger</span>
|
||||
<h4 id="commercial-customer-ledger-title">客户价值账</h4>
|
||||
<p>回答“客户是否真的省钱”:只把财务确认节省和明确收费口径带入 ROI。</p>
|
||||
</div>
|
||||
<i class="mdi mdi-domain" aria-hidden="true"></i>
|
||||
</header>
|
||||
<div class="commercial-metric-grid">
|
||||
<CommercialMetricCard v-for="metric in ledgers.customer" :key="metric.key" :metric="metric" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="commercial-ledger platform" aria-labelledby="commercial-platform-ledger-title">
|
||||
<header>
|
||||
<div>
|
||||
<span>Platform economics ledger</span>
|
||||
<h4 id="commercial-platform-ledger-title">平台经营账</h4>
|
||||
<p>回答“平台能否健康经营”:内部成本与合同收费代理值同币种匹配后才计算贡献毛利。</p>
|
||||
</div>
|
||||
<i class="mdi mdi-chart-box-outline" aria-hidden="true"></i>
|
||||
</header>
|
||||
<div class="commercial-metric-grid platform-grid">
|
||||
<CommercialMetricCard v-for="metric in ledgers.platform" :key="metric.key" :metric="metric" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CommercialMetricCard from './CommercialMetricCard.vue'
|
||||
import { statusLabel } from './commercialWorkspaceModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
analytics: { type: Object, default: null },
|
||||
ledgers: { type: Object, default: () => ({ customer: [], platform: [] }) },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: Object, default: null },
|
||||
platformAdmin: { type: Boolean, default: false },
|
||||
range: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reload', 'update-range'])
|
||||
|
||||
function updateRange(key, value) {
|
||||
emit('update-range', { ...props.range, [key]: value })
|
||||
}
|
||||
</script>
|
||||
179
web/src/components/commercial/CommercialWorkspace.vue
Normal file
179
web/src/components/commercial/CommercialWorkspace.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<main class="commercial-workspace" aria-labelledby="commercial-workspace-title">
|
||||
<header class="commercial-hero">
|
||||
<div>
|
||||
<span class="commercial-eyebrow">Commercial control & value proof</span>
|
||||
<h2 id="commercial-workspace-title">商业化管理与价值证明</h2>
|
||||
<p>把套餐、订阅、权益、用量、成本和企业节省放进可追溯闭环,同时保持客户价值账与平台经营账严格分离。</p>
|
||||
<div class="commercial-trust-row">
|
||||
<span><i class="mdi mdi-shield-lock-outline" aria-hidden="true"></i>租户隔离</span>
|
||||
<span><i class="mdi mdi-source-branch" aria-hidden="true"></i>版本化配置</span>
|
||||
<span><i class="mdi mdi-database-lock-outline" aria-hidden="true"></i>不可变计量</span>
|
||||
<span><i class="mdi mdi-currency-usd-off" aria-hidden="true"></i>币种不混算</span>
|
||||
</div>
|
||||
</div>
|
||||
<form v-if="platformAdmin" class="commercial-tenant-picker" @submit.prevent="applyTenant">
|
||||
<label for="commercial-tenant-id">目标租户 ID</label>
|
||||
<div>
|
||||
<input id="commercial-tenant-id" v-model.trim="tenantDraft" maxlength="64" autocomplete="off" placeholder="输入需要管理的租户 ID" />
|
||||
<button type="submit" class="commercial-button primary" :disabled="!tenantDraft || tenantDraft === activeTenantId">加载租户</button>
|
||||
</div>
|
||||
<small>平台管理员必须显式选择租户,避免误操作默认上下文。</small>
|
||||
</form>
|
||||
<div v-else class="commercial-readonly-badge">
|
||||
<i class="mdi mdi-account-check-outline" aria-hidden="true"></i>
|
||||
<div><strong>当前租户只读账户</strong><span>平台内部经营数据不会越权下发</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="platformAdmin && !activeTenantId" class="commercial-empty-workspace" role="status">
|
||||
<i class="mdi mdi-domain-plus" aria-hidden="true"></i>
|
||||
<strong>请先选择目标租户</strong>
|
||||
<span>输入租户 ID 后,系统才会读取该租户的商业账户、双账分析和可执行操作。</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="commercial-safety-banner" role="note">
|
||||
<i class="mdi mdi-shield-alert-outline" aria-hidden="true"></i>
|
||||
<div><strong>商业权益不能放宽安全规则</strong><span>即使配额充足,高风险审批、支付和其他安全门禁仍可要求人工复核或直接拒绝。</span></div>
|
||||
</div>
|
||||
|
||||
<CommercialAccountOverview
|
||||
:account="account"
|
||||
:state="accountState"
|
||||
:error="accountError"
|
||||
:loading="accountLoading"
|
||||
:quota-rows="quotaRows"
|
||||
@retry="retryAccount"
|
||||
/>
|
||||
|
||||
<CommercialValueProofPanel
|
||||
:analytics="analytics"
|
||||
:ledgers="ledgers"
|
||||
:loading="analyticsLoading"
|
||||
:error="analyticsError"
|
||||
:platform-admin="platformAdmin"
|
||||
:range="range"
|
||||
@reload="retryAnalytics"
|
||||
@update-range="updateRange"
|
||||
/>
|
||||
|
||||
<CommercialPricingScenarioPanel
|
||||
:tenant-id="activeTenantId"
|
||||
:platform-admin="platformAdmin"
|
||||
:range="range"
|
||||
:view="pricingScenarioView"
|
||||
:loading="pricingLoading"
|
||||
:error="pricingError"
|
||||
:execute="calculatePricingScenario"
|
||||
/>
|
||||
|
||||
<CommercialHistoryPanel
|
||||
:platform-admin="platformAdmin"
|
||||
:history="history"
|
||||
:loaded="historyLoaded"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
@load="loadHistory"
|
||||
@manage-subscription="openSubscriptionAction"
|
||||
/>
|
||||
|
||||
<CommercialAdminConsole
|
||||
ref="adminConsoleRef"
|
||||
:can-manage="canManage"
|
||||
:account="account"
|
||||
:busy="mutationLoading"
|
||||
:error="mutationError"
|
||||
:message="mutationMessage"
|
||||
:execute="executeMutation"
|
||||
/>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCommercialWorkspace } from '../../composables/useCommercialWorkspace.js'
|
||||
import { isPlatformAdminUser } from '../../utils/accessControl.js'
|
||||
import CommercialAccountOverview from './CommercialAccountOverview.vue'
|
||||
import CommercialAdminConsole from './CommercialAdminConsole.vue'
|
||||
import CommercialHistoryPanel from './CommercialHistoryPanel.vue'
|
||||
import CommercialPricingScenarioPanel from './CommercialPricingScenarioPanel.vue'
|
||||
import CommercialValueProofPanel from './CommercialValueProofPanel.vue'
|
||||
import './commercial-workspace.css'
|
||||
|
||||
const props = defineProps({
|
||||
tenantId: { type: String, default: '' },
|
||||
currentUser: { type: Object, default: () => ({}) },
|
||||
active: { type: Boolean, default: true }
|
||||
})
|
||||
const emit = defineEmits(['update:tenant-id'])
|
||||
const platformAdmin = computed(() => isPlatformAdminUser(props.currentUser))
|
||||
const tenantDraft = ref(String(props.tenantId || '').trim())
|
||||
const activeTenantId = ref(String(props.tenantId || '').trim())
|
||||
const adminConsoleRef = ref(null)
|
||||
const workspaceActive = computed(() => props.active && (!platformAdmin.value || Boolean(activeTenantId.value)))
|
||||
|
||||
const {
|
||||
account,
|
||||
accountLoading,
|
||||
accountError,
|
||||
accountState,
|
||||
analytics,
|
||||
analyticsLoading,
|
||||
analyticsError,
|
||||
mutationLoading,
|
||||
mutationError,
|
||||
mutationMessage,
|
||||
pricingScenarioView,
|
||||
pricingLoading,
|
||||
pricingError,
|
||||
history,
|
||||
historyLoading,
|
||||
historyError,
|
||||
historyLoaded,
|
||||
range,
|
||||
canManage,
|
||||
quotaRows,
|
||||
ledgers,
|
||||
loadAccount,
|
||||
loadAnalytics,
|
||||
loadHistory,
|
||||
calculatePricingScenario,
|
||||
executeMutation
|
||||
} = useCommercialWorkspace({
|
||||
tenantId: activeTenantId,
|
||||
platformAdmin,
|
||||
active: workspaceActive
|
||||
})
|
||||
|
||||
watch(() => props.tenantId, (value) => {
|
||||
const normalized = String(value || '').trim()
|
||||
tenantDraft.value = normalized
|
||||
activeTenantId.value = normalized
|
||||
})
|
||||
|
||||
function applyTenant() {
|
||||
const normalized = tenantDraft.value.trim()
|
||||
if (!normalized) return
|
||||
activeTenantId.value = normalized
|
||||
emit('update:tenant-id', normalized)
|
||||
}
|
||||
|
||||
function updateRange(nextRange) {
|
||||
Object.assign(range, nextRange)
|
||||
}
|
||||
|
||||
function retryAccount() {
|
||||
return loadAccount().catch(() => null)
|
||||
}
|
||||
|
||||
function retryAnalytics() {
|
||||
return loadAnalytics().catch(() => null)
|
||||
}
|
||||
|
||||
function openSubscriptionAction(command) {
|
||||
adminConsoleRef.value?.openAction?.(command.kind, command.form)
|
||||
}
|
||||
</script>
|
||||
679
web/src/components/commercial/commercial-workspace.css
Normal file
679
web/src/components/commercial/commercial-workspace.css
Normal file
@@ -0,0 +1,679 @@
|
||||
.commercial-workspace {
|
||||
--commercial-ink: #0f172a;
|
||||
--commercial-copy: #475569;
|
||||
--commercial-muted: #64748b;
|
||||
--commercial-line: #dbe5ef;
|
||||
--commercial-surface: rgba(255, 255, 255, 0.96);
|
||||
--commercial-primary: var(--theme-primary, #2563eb);
|
||||
--commercial-primary-deep: var(--theme-primary-active, #1d4ed8);
|
||||
--commercial-primary-soft: var(--theme-primary-soft, #eff6ff);
|
||||
--commercial-success: #047857;
|
||||
--commercial-success-soft: #ecfdf5;
|
||||
--commercial-warning: #b45309;
|
||||
--commercial-warning-soft: #fffbeb;
|
||||
--commercial-danger: #b91c1c;
|
||||
--commercial-danger-soft: #fef2f2;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
color: var(--commercial-ink);
|
||||
}
|
||||
|
||||
.commercial-workspace *,
|
||||
.commercial-workspace *::before,
|
||||
.commercial-workspace *::after,
|
||||
.commercial-dialog *,
|
||||
.commercial-dialog *::before,
|
||||
.commercial-dialog *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.commercial-hero,
|
||||
.commercial-section,
|
||||
.commercial-empty-workspace,
|
||||
.commercial-safety-banner {
|
||||
border: 1px solid var(--commercial-line);
|
||||
border-radius: 12px;
|
||||
background: var(--commercial-surface);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.commercial-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 390px);
|
||||
gap: 24px;
|
||||
padding: 28px;
|
||||
background:
|
||||
radial-gradient(circle at 88% 10%, rgba(var(--theme-primary-rgb, 37, 99, 235), 0.14), transparent 32%),
|
||||
linear-gradient(135deg, #ffffff, #f8fbff);
|
||||
}
|
||||
|
||||
.commercial-eyebrow,
|
||||
.commercial-section-head > div > span,
|
||||
.commercial-ledger > header span,
|
||||
.commercial-dialog > header span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: var(--commercial-primary-deep);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.commercial-hero h2,
|
||||
.commercial-section h3,
|
||||
.commercial-ledger h4,
|
||||
.commercial-dialog h3 {
|
||||
margin: 0;
|
||||
color: var(--commercial-ink);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.commercial-hero h2 { font-size: clamp(24px, 3vw, 34px); }
|
||||
.commercial-section h3 { font-size: 21px; }
|
||||
.commercial-hero p,
|
||||
.commercial-section-head p,
|
||||
.commercial-ledger > header p,
|
||||
.commercial-dialog > header p {
|
||||
margin: 9px 0 0;
|
||||
color: var(--commercial-copy);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.commercial-trust-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.commercial-trust-row span {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 37, 99, 235), 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: var(--commercial-primary-deep);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.commercial-tenant-picker,
|
||||
.commercial-readonly-badge {
|
||||
align-self: center;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 37, 99, 235), 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.commercial-tenant-picker > label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--commercial-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commercial-tenant-picker > div { display: flex; gap: 8px; }
|
||||
.commercial-tenant-picker input { min-width: 0; flex: 1; }
|
||||
.commercial-tenant-picker small,
|
||||
.commercial-form-grid label small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--commercial-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.commercial-readonly-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.commercial-readonly-badge > i { color: var(--commercial-primary); font-size: 30px; }
|
||||
.commercial-readonly-badge strong,
|
||||
.commercial-readonly-badge span { display: block; }
|
||||
.commercial-readonly-badge span { margin-top: 3px; color: var(--commercial-muted); font-size: 12px; }
|
||||
|
||||
.commercial-section { padding: 24px; }
|
||||
.commercial-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.commercial-section-head > div { max-width: 760px; }
|
||||
.commercial-button,
|
||||
.commercial-icon-button,
|
||||
.commercial-action-button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
transition: background 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.commercial-button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commercial-button.primary { background: var(--commercial-primary); color: #fff; }
|
||||
.commercial-button.primary:hover:not(:disabled) { background: var(--commercial-primary-deep); }
|
||||
.commercial-button.secondary { border-color: var(--commercial-line); background: #fff; color: var(--commercial-ink); }
|
||||
.commercial-button.secondary:hover:not(:disabled),
|
||||
.commercial-button.ghost:hover:not(:disabled) { border-color: var(--commercial-primary); color: var(--commercial-primary-deep); }
|
||||
.commercial-button.ghost { border-color: transparent; background: transparent; color: var(--commercial-copy); }
|
||||
.commercial-button:disabled,
|
||||
.commercial-icon-button:disabled,
|
||||
.commercial-action-button:disabled { cursor: not-allowed; opacity: 0.48; }
|
||||
|
||||
.commercial-workspace button:focus-visible,
|
||||
.commercial-workspace input:focus-visible,
|
||||
.commercial-workspace select:focus-visible,
|
||||
.commercial-workspace textarea:focus-visible,
|
||||
.commercial-dialog button:focus-visible,
|
||||
.commercial-dialog input:focus-visible,
|
||||
.commercial-dialog select:focus-visible,
|
||||
.commercial-dialog textarea:focus-visible,
|
||||
.commercial-metric-card summary:focus-visible,
|
||||
.commercial-pricing-notes summary:focus-visible,
|
||||
.commercial-pricing-confirmation:focus-visible,
|
||||
.commercial-form-error:focus-visible,
|
||||
.commercial-operation-feedback:focus-visible {
|
||||
outline: 3px solid rgba(var(--theme-primary-rgb, 37, 99, 235), 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.commercial-workspace input,
|
||||
.commercial-workspace select,
|
||||
.commercial-dialog input,
|
||||
.commercial-dialog select,
|
||||
.commercial-dialog textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--commercial-ink);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.commercial-dialog textarea { min-height: 90px; resize: vertical; line-height: 1.55; }
|
||||
.commercial-empty-workspace {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.commercial-empty-workspace i { color: var(--commercial-primary); font-size: 48px; }
|
||||
.commercial-empty-workspace strong { font-size: 20px; }
|
||||
.commercial-empty-workspace span { max-width: 560px; color: var(--commercial-copy); line-height: 1.6; }
|
||||
.commercial-safety-banner,
|
||||
.commercial-state-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.commercial-safety-banner { border-color: rgba(180, 83, 9, 0.24); background: var(--commercial-warning-soft); box-shadow: none; }
|
||||
.commercial-safety-banner > i,
|
||||
.commercial-state-card > i { flex: 0 0 auto; color: var(--commercial-warning); font-size: 26px; }
|
||||
.commercial-safety-banner strong,
|
||||
.commercial-safety-banner span,
|
||||
.commercial-state-card strong,
|
||||
.commercial-state-card span { display: block; }
|
||||
.commercial-safety-banner span,
|
||||
.commercial-state-card span { margin-top: 3px; color: var(--commercial-copy); font-size: 13px; line-height: 1.55; }
|
||||
|
||||
.commercial-state-card {
|
||||
min-height: 96px;
|
||||
border: 1px dashed var(--commercial-line);
|
||||
border-radius: 9px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.commercial-state-card > div { flex: 1; }
|
||||
.commercial-state-card.danger { border-color: rgba(185, 28, 28, 0.25); background: var(--commercial-danger-soft); }
|
||||
.commercial-state-card.danger > i { color: var(--commercial-danger); }
|
||||
.commercial-state-card.warning { border-color: rgba(180, 83, 9, 0.25); background: var(--commercial-warning-soft); }
|
||||
.commercial-state-card.permission { border-color: rgba(var(--theme-primary-rgb, 37, 99, 235), 0.23); background: var(--commercial-primary-soft); }
|
||||
.commercial-state-card.permission > i { color: var(--commercial-primary); }
|
||||
|
||||
.commercial-inline-warning,
|
||||
.commercial-operation-feedback,
|
||||
.commercial-quality-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(180, 83, 9, 0.24);
|
||||
border-radius: 8px;
|
||||
background: var(--commercial-warning-soft);
|
||||
color: var(--commercial-warning);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.commercial-inline-warning.danger,
|
||||
.commercial-operation-feedback.danger { border-color: rgba(185, 28, 28, 0.22); background: var(--commercial-danger-soft); color: var(--commercial-danger); }
|
||||
.commercial-operation-feedback.success { border-color: rgba(4, 120, 87, 0.2); background: var(--commercial-success-soft); color: var(--commercial-success); }
|
||||
|
||||
.commercial-account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.commercial-account-card,
|
||||
.commercial-quota-panel,
|
||||
.commercial-ledger,
|
||||
.commercial-admin-groups > article {
|
||||
border: 1px solid var(--commercial-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.commercial-account-card { padding: 18px; }
|
||||
.commercial-account-card > span { color: var(--commercial-muted); font-size: 12px; font-weight: 750; }
|
||||
.commercial-card-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 8px 0 14px; }
|
||||
.commercial-card-title strong { font-size: 18px; }
|
||||
.commercial-card-title em,
|
||||
.commercial-admin-access {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--commercial-primary-soft);
|
||||
color: var(--commercial-primary-deep);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commercial-account-card dl { display: grid; gap: 8px; margin: 0; }
|
||||
.commercial-account-card dl div { display: flex; justify-content: space-between; gap: 12px; padding-top: 8px; border-top: 1px solid #eef2f7; }
|
||||
.commercial-account-card dt { color: var(--commercial-muted); font-size: 13px; }
|
||||
.commercial-account-card dd { margin: 0; text-align: right; font-size: 13px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.commercial-quota-panel { margin-top: 14px; overflow: hidden; }
|
||||
.commercial-quota-panel > header { display: flex; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--commercial-line); }
|
||||
.commercial-quota-panel h4 { margin: 0; font-size: 16px; }
|
||||
.commercial-quota-panel p { margin: 4px 0 0; color: var(--commercial-muted); font-size: 12px; }
|
||||
.commercial-quota-panel > header > span { align-self: center; color: var(--commercial-primary-deep); font-size: 12px; font-weight: 800; }
|
||||
.commercial-table-wrap { overflow-x: auto; }
|
||||
.commercial-table-wrap table { width: 100%; min-width: 820px; border-collapse: collapse; }
|
||||
.commercial-table-wrap th,
|
||||
.commercial-table-wrap td { padding: 12px 14px; border-bottom: 1px solid #eef2f7; text-align: left; font-size: 13px; vertical-align: middle; }
|
||||
.commercial-table-wrap th { background: #f8fafc; color: var(--commercial-muted); font-size: 12px; font-weight: 800; }
|
||||
.commercial-table-wrap td small { display: block; margin-top: 3px; color: var(--commercial-muted); }
|
||||
.commercial-table-wrap td.numeric { font-variant-numeric: tabular-nums; }
|
||||
.commercial-status-pill { display: inline-flex; padding: 4px 8px; border-radius: 999px; background: var(--commercial-primary-soft); color: var(--commercial-primary-deep); font-size: 12px; font-weight: 800; white-space: nowrap; }
|
||||
.commercial-status-pill.approaching { background: var(--commercial-warning-soft); color: var(--commercial-warning); }
|
||||
.commercial-status-pill.exhausted,
|
||||
.commercial-status-pill.inactive { background: var(--commercial-danger-soft); color: var(--commercial-danger); }
|
||||
.commercial-status-pill.unlimited { background: var(--commercial-success-soft); color: var(--commercial-success); }
|
||||
.commercial-status-pill.complete,
|
||||
.commercial-status-pill.feasible { background: var(--commercial-success-soft); color: var(--commercial-success); }
|
||||
.commercial-status-pill.partial,
|
||||
.commercial-status-pill.cost_only,
|
||||
.commercial-status-pill.insufficient_value { background: var(--commercial-warning-soft); color: var(--commercial-warning); }
|
||||
.commercial-status-pill.unavailable { background: var(--commercial-danger-soft); color: var(--commercial-danger); }
|
||||
.commercial-table-empty { min-height: 110px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 20px; color: var(--commercial-muted); font-size: 13px; text-align: center; }
|
||||
.commercial-table-empty i { font-size: 24px; }
|
||||
|
||||
.commercial-range-form { display: flex; align-items: end; gap: 8px; flex-wrap: wrap; }
|
||||
.commercial-range-form label { display: grid; gap: 5px; color: var(--commercial-muted); font-size: 12px; font-weight: 750; }
|
||||
.commercial-range-form input { min-width: 146px; }
|
||||
.commercial-quality-banner { align-items: center; border-color: var(--commercial-line); background: #f8fafc; color: var(--commercial-copy); }
|
||||
.commercial-quality-banner.partial { border-color: rgba(180, 83, 9, 0.22); background: var(--commercial-warning-soft); }
|
||||
.commercial-quality-banner.unavailable { border-color: rgba(185, 28, 28, 0.22); background: var(--commercial-danger-soft); }
|
||||
.commercial-quality-banner strong,
|
||||
.commercial-quality-banner span { display: block; }
|
||||
.commercial-quality-banner span { margin-top: 3px; font-size: 12px; line-height: 1.5; }
|
||||
|
||||
.commercial-ledger { margin-top: 16px; padding: 18px; }
|
||||
.commercial-ledger > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
|
||||
.commercial-ledger > header > i { color: var(--commercial-primary); font-size: 34px; }
|
||||
.commercial-ledger.platform > header > i { color: var(--commercial-success); }
|
||||
.commercial-ledger h4 { font-size: 18px; }
|
||||
.commercial-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.commercial-metric-grid.platform-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.commercial-metric-card { min-width: 0; padding: 16px; border: 1px solid var(--commercial-line); border-radius: 9px; background: #fff; }
|
||||
.commercial-metric-card.partial { border-color: rgba(180, 83, 9, 0.24); }
|
||||
.commercial-metric-card.unavailable { background: #f8fafc; }
|
||||
.commercial-metric-card > header { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.commercial-metric-card > header span { color: var(--commercial-muted); font-size: 10px; font-weight: 800; text-transform: uppercase; }
|
||||
.commercial-metric-card h4 { margin: 4px 0 0; font-size: 14px; }
|
||||
.commercial-metric-card > header em { height: fit-content; padding: 3px 7px; border-radius: 999px; background: var(--commercial-primary-soft); color: var(--commercial-primary-deep); font-size: 10px; font-style: normal; font-weight: 800; white-space: nowrap; }
|
||||
.commercial-metric-card.partial > header em { background: var(--commercial-warning-soft); color: var(--commercial-warning); }
|
||||
.commercial-metric-card.unavailable > header em { background: #e2e8f0; color: #475569; }
|
||||
.commercial-metric-values { display: grid; gap: 8px; margin-top: 15px; }
|
||||
.commercial-metric-values > div { padding-bottom: 8px; border-bottom: 1px solid #eef2f7; }
|
||||
.commercial-metric-values strong,
|
||||
.commercial-metric-values small { display: block; }
|
||||
.commercial-metric-values strong,
|
||||
.commercial-unavailable-value { color: var(--commercial-ink); font-size: 20px; font-variant-numeric: tabular-nums; }
|
||||
.commercial-metric-values small { margin-top: 3px; color: var(--commercial-muted); font-size: 10px; line-height: 1.4; }
|
||||
.commercial-unavailable-value { display: block; margin-top: 15px; color: #64748b; }
|
||||
.commercial-metric-card > p { margin: 10px 0 0; color: var(--commercial-copy); font-size: 12px; line-height: 1.55; }
|
||||
.commercial-metric-card .commercial-currency-guard { color: var(--commercial-primary-deep); }
|
||||
.commercial-metric-card details { margin-top: 10px; color: var(--commercial-copy); font-size: 12px; }
|
||||
.commercial-metric-card summary { min-height: 32px; display: flex; align-items: center; cursor: pointer; color: var(--commercial-primary-deep); font-weight: 750; }
|
||||
.commercial-metric-card ul { margin: 7px 0 0; padding-left: 18px; line-height: 1.55; }
|
||||
|
||||
.commercial-pricing-access {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commercial-pricing-access.allowed { background: var(--commercial-success-soft); color: var(--commercial-success); }
|
||||
.commercial-pricing-access.denied { background: #f1f5f9; color: var(--commercial-muted); }
|
||||
.commercial-operation-feedback.pricing-progress { border-color: var(--commercial-line); background: #f8fafc; color: var(--commercial-primary-deep); }
|
||||
.commercial-operation-feedback > span strong { display: block; margin-bottom: 2px; }
|
||||
.commercial-pricing-form {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--commercial-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.commercial-pricing-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.commercial-pricing-form legend { margin-bottom: 14px; color: var(--commercial-ink); font-size: 15px; font-weight: 800; }
|
||||
.commercial-pricing-fields { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
.commercial-pricing-fields label { min-width: 0; color: #334155; font-size: 13px; font-weight: 750; }
|
||||
.commercial-pricing-fields label > span { display: block; margin-bottom: 6px; }
|
||||
.commercial-pricing-fields label small { display: block; margin-top: 6px; color: var(--commercial-muted); font-size: 11px; font-weight: 500; line-height: 1.45; }
|
||||
.commercial-percent-input { position: relative; }
|
||||
.commercial-percent-input input { padding-right: 36px; font-variant-numeric: tabular-nums; }
|
||||
.commercial-percent-input > span { position: absolute; top: 50%; right: 12px; color: var(--commercial-muted); font-size: 13px; transform: translateY(-50%); }
|
||||
.commercial-pricing-form-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; }
|
||||
.commercial-pricing-form-actions > span { color: var(--commercial-muted); font-size: 12px; line-height: 1.5; }
|
||||
|
||||
.commercial-pricing-confirmation {
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 37, 99, 235), 0.22);
|
||||
border-radius: 10px;
|
||||
background: var(--commercial-primary-soft);
|
||||
}
|
||||
|
||||
.commercial-pricing-confirmation > header { display: flex; align-items: center; gap: 12px; }
|
||||
.commercial-pricing-confirmation > header > i { color: var(--commercial-primary); font-size: 30px; }
|
||||
.commercial-pricing-confirmation > header strong,
|
||||
.commercial-pricing-confirmation > header span { display: block; }
|
||||
.commercial-pricing-confirmation > header strong { font-size: 16px; }
|
||||
.commercial-pricing-confirmation > header span { margin-top: 3px; color: var(--commercial-copy); font-size: 12px; }
|
||||
.commercial-pricing-confirmation dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 16px 0 0; }
|
||||
.commercial-pricing-confirmation dl div { padding: 11px; border: 1px solid rgba(var(--theme-primary-rgb, 37, 99, 235), 0.15); border-radius: 7px; background: #fff; }
|
||||
.commercial-pricing-confirmation dt { color: var(--commercial-muted); font-size: 11px; }
|
||||
.commercial-pricing-confirmation dd { margin: 5px 0 0; color: var(--commercial-ink); font-size: 13px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.45; }
|
||||
.commercial-pricing-confirmation footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
|
||||
.commercial-pricing-empty {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
border: 1px dashed var(--commercial-line);
|
||||
border-radius: 9px;
|
||||
background: #f8fafc;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.commercial-pricing-empty > i { color: var(--commercial-primary); font-size: 32px; }
|
||||
.commercial-pricing-empty strong,
|
||||
.commercial-pricing-empty span { display: block; }
|
||||
.commercial-pricing-empty span { margin-top: 4px; color: var(--commercial-copy); font-size: 13px; line-height: 1.5; }
|
||||
.commercial-pricing-result { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--commercial-line); }
|
||||
.commercial-pricing-result > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.commercial-pricing-result > header > div > span { color: var(--commercial-primary-deep); font-size: 11px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.commercial-pricing-result h4 { margin: 4px 0 0; font-size: 18px; }
|
||||
.commercial-pricing-result > header p { margin: 5px 0 0; color: var(--commercial-muted); font-size: 12px; }
|
||||
.commercial-pricing-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 14px 0; }
|
||||
.commercial-pricing-summary article { padding: 14px; border: 1px solid var(--commercial-line); border-radius: 8px; background: #f8fafc; }
|
||||
.commercial-pricing-summary span,
|
||||
.commercial-pricing-summary strong,
|
||||
.commercial-pricing-summary small { display: block; }
|
||||
.commercial-pricing-summary span { color: var(--commercial-muted); font-size: 11px; font-weight: 750; }
|
||||
.commercial-pricing-summary strong { margin-top: 5px; font-size: 15px; line-height: 1.45; }
|
||||
.commercial-pricing-summary small { margin-top: 4px; color: var(--commercial-copy); font-size: 11px; line-height: 1.45; }
|
||||
.commercial-pricing-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.commercial-pricing-card { min-width: 0; padding: 16px; border: 1px solid var(--commercial-line); border-radius: 9px; background: #fff; }
|
||||
.commercial-pricing-card.feasible { border-color: rgba(4, 120, 87, 0.25); }
|
||||
.commercial-pricing-card.insufficient_value,
|
||||
.commercial-pricing-card.cost_only { border-color: rgba(180, 83, 9, 0.27); }
|
||||
.commercial-pricing-card.unavailable { border-color: rgba(185, 28, 28, 0.22); background: #f8fafc; }
|
||||
.commercial-pricing-card > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.commercial-pricing-card > header span,
|
||||
.commercial-pricing-card > header strong { display: block; }
|
||||
.commercial-pricing-card > header span { color: var(--commercial-muted); font-size: 10px; font-weight: 750; text-transform: uppercase; }
|
||||
.commercial-pricing-card > header strong { margin-top: 2px; font-size: 20px; }
|
||||
.commercial-pricing-card > header em { padding: 4px 8px; border-radius: 999px; background: var(--commercial-primary-soft); color: var(--commercial-primary-deep); font-size: 11px; font-style: normal; font-weight: 800; }
|
||||
.commercial-pricing-card.insufficient_value > header em,
|
||||
.commercial-pricing-card.cost_only > header em { background: var(--commercial-warning-soft); color: var(--commercial-warning); }
|
||||
.commercial-pricing-card.unavailable > header em { background: var(--commercial-danger-soft); color: var(--commercial-danger); }
|
||||
.commercial-pricing-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; margin: 14px 0 0; }
|
||||
.commercial-pricing-card dl div { min-width: 0; padding: 9px 0; border-top: 1px solid #eef2f7; }
|
||||
.commercial-pricing-card dt { color: var(--commercial-muted); font-size: 11px; }
|
||||
.commercial-pricing-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--commercial-ink); font-size: 13px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.commercial-pricing-card .pricing-bound dd { color: var(--commercial-primary-deep); font-size: 15px; }
|
||||
.commercial-pricing-card > p { margin: 12px 0 0; color: var(--commercial-copy); font-size: 12px; line-height: 1.55; }
|
||||
.commercial-pricing-missing { margin-top: 12px; padding: 10px 12px; border-radius: 7px; background: var(--commercial-warning-soft); color: var(--commercial-warning); font-size: 12px; }
|
||||
.commercial-pricing-missing ul { margin: 5px 0 0; padding-left: 18px; line-height: 1.5; }
|
||||
.commercial-pricing-notes { margin-top: 12px; color: var(--commercial-copy); font-size: 12px; }
|
||||
.commercial-pricing-notes summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; color: var(--commercial-primary-deep); font-weight: 800; }
|
||||
.commercial-pricing-notes ul { margin: 4px 0 0; padding: 12px 12px 12px 30px; border-radius: 8px; background: #f8fafc; line-height: 1.6; }
|
||||
|
||||
.commercial-admin-access.allowed { background: var(--commercial-success-soft); color: var(--commercial-success); }
|
||||
.commercial-admin-access.denied { background: #f1f5f9; color: var(--commercial-muted); }
|
||||
.commercial-admin-groups { display: grid; grid-template-columns: 2fr 1fr; gap: 14px; }
|
||||
.commercial-admin-groups > article { overflow: hidden; }
|
||||
.commercial-admin-groups article > header { display: flex; align-items: center; gap: 10px; padding: 15px 16px; border-bottom: 1px solid var(--commercial-line); background: #f8fafc; }
|
||||
.commercial-admin-groups article > header > i { color: var(--commercial-primary); font-size: 24px; }
|
||||
.commercial-admin-groups article > header strong,
|
||||
.commercial-admin-groups article > header span { display: block; }
|
||||
.commercial-admin-groups article > header span { margin-top: 2px; color: var(--commercial-muted); font-size: 11px; }
|
||||
.commercial-admin-groups article > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 12px; }
|
||||
.commercial-admin-groups article:last-child > div { grid-template-columns: 1fr; }
|
||||
.commercial-action-button { min-height: 46px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 12px; border: 1px solid var(--commercial-line); border-radius: 7px; background: #fff; color: var(--commercial-ink); font-size: 13px; font-weight: 750; text-align: left; }
|
||||
.commercial-action-button:hover:not(:disabled) { border-color: var(--commercial-primary); color: var(--commercial-primary-deep); }
|
||||
|
||||
.commercial-history-empty {
|
||||
min-height: 170px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 24px;
|
||||
border: 1px dashed var(--commercial-line);
|
||||
border-radius: 9px;
|
||||
color: var(--commercial-copy);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.commercial-history-empty > i { color: var(--commercial-primary); font-size: 34px; }
|
||||
.commercial-history-empty > strong { color: var(--commercial-ink); font-size: 16px; }
|
||||
.commercial-history-empty > span { margin-bottom: 6px; font-size: 13px; line-height: 1.55; }
|
||||
.commercial-history-tabs {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 2px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.commercial-history-tabs button {
|
||||
min-height: 44px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--commercial-line);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--commercial-copy);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commercial-history-tabs button span { min-width: 20px; padding: 2px 5px; border-radius: 999px; background: #f1f5f9; font-size: 10px; text-align: center; }
|
||||
.commercial-history-tabs button.active { border-color: var(--commercial-primary); background: var(--commercial-primary-soft); color: var(--commercial-primary-deep); }
|
||||
.commercial-history-tabs button.active span { background: #fff; }
|
||||
.commercial-history-table { border: 1px solid var(--commercial-line); border-radius: 9px; }
|
||||
.commercial-history-table table { min-width: 940px; }
|
||||
.commercial-history-table-empty { min-height: 150px; border: 1px dashed var(--commercial-line); border-radius: 9px; }
|
||||
.commercial-mono { max-width: 210px; overflow-wrap: anywhere; color: var(--commercial-copy); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 11px !important; }
|
||||
.commercial-history-actions { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.commercial-history-actions button {
|
||||
min-height: 44px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--commercial-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--commercial-primary-deep);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commercial-history-actions button:hover { border-color: var(--commercial-primary); }
|
||||
.commercial-history-actions > span { color: var(--commercial-muted); font-size: 11px; }
|
||||
|
||||
.commercial-dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.54);
|
||||
backdrop-filter: blur(7px);
|
||||
-webkit-backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.commercial-dialog {
|
||||
width: min(760px, calc(100vw - 40px));
|
||||
max-height: calc(100dvh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 37, 99, 235), 0.2);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.28);
|
||||
overflow: hidden;
|
||||
color: var(--commercial-ink, #0f172a);
|
||||
}
|
||||
|
||||
.commercial-dialog > header { flex: 0 0 auto; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 22px; border-bottom: 1px solid #e2e8f0; }
|
||||
.commercial-dialog > header > div { max-width: 630px; }
|
||||
.commercial-dialog h3 { font-size: 21px; }
|
||||
.commercial-icon-button { width: 44px; height: 44px; flex: 0 0 auto; border: 1px solid #e2e8f0; border-radius: 7px; background: #fff; color: #475569; font-size: 20px; }
|
||||
.commercial-icon-button:hover:not(:disabled) { border-color: var(--commercial-primary, #2563eb); color: var(--commercial-primary-deep, #1d4ed8); }
|
||||
.commercial-dialog-form,
|
||||
.commercial-confirmation { min-height: 0; overflow-y: auto; padding: 20px 22px; }
|
||||
.commercial-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.commercial-form-grid label { min-width: 0; color: #334155; font-size: 13px; font-weight: 750; }
|
||||
.commercial-form-grid label > span { display: block; margin-bottom: 6px; }
|
||||
.commercial-form-grid .commercial-form-wide { grid-column: 1 / -1; }
|
||||
.commercial-check-label { min-height: 44px; align-self: end; display: flex; align-items: center; gap: 9px; padding: 0 4px; }
|
||||
.commercial-check-label input { width: 20px; min-height: 20px; }
|
||||
.commercial-check-label > span { margin: 0 !important; }
|
||||
.commercial-form-note { display: flex; align-items: center; gap: 8px; margin: 0; padding: 12px; border-radius: 7px; background: var(--commercial-primary-soft, #eff6ff); color: var(--commercial-primary-deep, #1d4ed8); font-size: 13px; line-height: 1.55; }
|
||||
.commercial-form-error { margin: 14px 0 0; padding: 11px 13px; border: 1px solid rgba(185, 28, 28, 0.22); border-radius: 7px; background: #fef2f2; color: #b91c1c; font-size: 13px; line-height: 1.5; }
|
||||
.commercial-dialog footer { display: flex; justify-content: flex-end; gap: 9px; margin-top: 20px; }
|
||||
.commercial-confirmation { text-align: center; }
|
||||
.commercial-confirmation-icon { width: 58px; height: 58px; display: grid; place-items: center; margin: 2px auto 12px; border-radius: 50%; background: var(--commercial-primary-soft, #eff6ff); color: var(--commercial-primary, #2563eb); font-size: 30px; }
|
||||
.commercial-confirmation > strong { font-size: 19px; }
|
||||
.commercial-confirmation > p { margin: 8px auto 0; max-width: 580px; color: #475569; font-size: 13px; line-height: 1.6; }
|
||||
.commercial-confirmation ul { margin: 18px 0 0; padding: 0; list-style: none; text-align: left; }
|
||||
.commercial-confirmation li { padding: 10px 12px; border-bottom: 1px solid #eef2f7; color: #334155; font-size: 13px; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.commercial-hero { grid-template-columns: 1fr; }
|
||||
.commercial-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.commercial-pricing-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.commercial-hero,
|
||||
.commercial-section { padding: 18px; }
|
||||
.commercial-section-head { flex-direction: column; }
|
||||
.commercial-section-head > .commercial-button { width: 100%; }
|
||||
.commercial-account-grid,
|
||||
.commercial-admin-groups,
|
||||
.commercial-metric-grid,
|
||||
.commercial-metric-grid.platform-grid,
|
||||
.commercial-pricing-fields,
|
||||
.commercial-pricing-summary,
|
||||
.commercial-pricing-grid,
|
||||
.commercial-pricing-confirmation dl { grid-template-columns: 1fr; }
|
||||
.commercial-range-form { width: 100%; display: grid; grid-template-columns: 1fr; }
|
||||
.commercial-range-form label,
|
||||
.commercial-range-form input,
|
||||
.commercial-range-form button { width: 100%; }
|
||||
.commercial-admin-groups article > div { grid-template-columns: 1fr; }
|
||||
.commercial-pricing-form { padding: 14px; }
|
||||
.commercial-pricing-form-actions,
|
||||
.commercial-pricing-confirmation footer { align-items: stretch; flex-direction: column; }
|
||||
.commercial-pricing-form-actions .commercial-button,
|
||||
.commercial-pricing-confirmation footer .commercial-button { width: 100%; }
|
||||
.commercial-pricing-result > header { flex-direction: column; }
|
||||
.commercial-pricing-card dl { grid-template-columns: 1fr; }
|
||||
.commercial-tenant-picker > div { flex-direction: column; }
|
||||
.commercial-dialog-mask { padding: 10px; }
|
||||
.commercial-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 20px); }
|
||||
.commercial-dialog > header,
|
||||
.commercial-dialog-form,
|
||||
.commercial-confirmation { padding: 16px; }
|
||||
.commercial-form-grid { grid-template-columns: 1fr; }
|
||||
.commercial-form-grid .commercial-form-wide { grid-column: auto; }
|
||||
.commercial-dialog footer { flex-direction: column-reverse; }
|
||||
.commercial-dialog footer .commercial-button { width: 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.commercial-button,
|
||||
.commercial-icon-button,
|
||||
.commercial-action-button { transition: none; }
|
||||
}
|
||||
226
web/src/components/commercial/commercialFormModel.js
Normal file
226
web/src/components/commercial/commercialFormModel.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import { COMMERCIAL_ACTIONS } from './commercialWorkspaceModel.js'
|
||||
|
||||
let idempotencySequence = 0
|
||||
|
||||
function toLocalDateTime(value) {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
const pad = (item) => String(item).padStart(2, '0')
|
||||
return [
|
||||
date.getFullYear(),
|
||||
'-',
|
||||
pad(date.getMonth() + 1),
|
||||
'-',
|
||||
pad(date.getDate()),
|
||||
'T',
|
||||
pad(date.getHours()),
|
||||
':',
|
||||
pad(date.getMinutes())
|
||||
].join('')
|
||||
}
|
||||
|
||||
function nextMonth(value) {
|
||||
const date = new Date(value)
|
||||
date.setMonth(date.getMonth() + 1)
|
||||
return date
|
||||
}
|
||||
|
||||
function createIdempotencyKey(prefix) {
|
||||
idempotencySequence += 1
|
||||
const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${idempotencySequence}`
|
||||
return `${prefix}-${random}`
|
||||
}
|
||||
|
||||
export function createCommercialForm(kind, account = null, now = new Date()) {
|
||||
const current = toLocalDateTime(now)
|
||||
const monthEnd = toLocalDateTime(nextMonth(now))
|
||||
const plan = account?.plan || {}
|
||||
const subscription = account?.subscription || {}
|
||||
const firstQuota = account?.quotas?.[0] || {}
|
||||
const entitlement = firstQuota.entitlement || {}
|
||||
|
||||
if (kind === 'createPlan') {
|
||||
return {
|
||||
planCode: String(plan.planCode || ''),
|
||||
name: '',
|
||||
pricingModel: 'subscription',
|
||||
billingInterval: 'annual',
|
||||
currency: String(plan.currency || 'CNY'),
|
||||
baseFee: '0',
|
||||
includedSeats: Number(plan.includedSeats || 1),
|
||||
overageEnabled: false,
|
||||
effectiveFrom: current,
|
||||
effectiveTo: '',
|
||||
contractTermsJson: '{}'
|
||||
}
|
||||
}
|
||||
if (kind === 'createSubscription') {
|
||||
return {
|
||||
subscriptionKey: '',
|
||||
planId: String(plan.id || ''),
|
||||
status: 'active',
|
||||
startsAt: current,
|
||||
endsAt: '',
|
||||
currentPeriodStart: current,
|
||||
currentPeriodEnd: monthEnd,
|
||||
seats: Math.max(1, Number(plan.includedSeats || subscription.seats || 1)),
|
||||
autoRenew: false,
|
||||
externalProvider: '',
|
||||
externalSubscriptionId: '',
|
||||
metadataJson: '{}'
|
||||
}
|
||||
}
|
||||
if (kind === 'upsertEntitlement') {
|
||||
return {
|
||||
subscriptionId: String(subscription.id || ''),
|
||||
entitlementKey: '',
|
||||
metricKey: '',
|
||||
entitlementType: 'metered',
|
||||
unit: '次',
|
||||
includedQuantity: '0',
|
||||
hardLimitQuantity: '',
|
||||
resetInterval: 'monthly',
|
||||
overagePolicy: 'block',
|
||||
status: 'active',
|
||||
effectiveFrom: current,
|
||||
effectiveTo: '',
|
||||
configJson: '{}'
|
||||
}
|
||||
}
|
||||
if (kind === 'recordUsage') {
|
||||
return {
|
||||
subscriptionId: String(subscription.id || ''),
|
||||
entitlementId: String(entitlement.id || ''),
|
||||
eventType: 'usage',
|
||||
quantity: '1',
|
||||
occurredAt: current,
|
||||
sourceSystem: 'x-financial-web',
|
||||
idempotencyKey: createIdempotencyKey('usage'),
|
||||
reversalOfEventId: '',
|
||||
subjectType: '',
|
||||
subjectId: '',
|
||||
correlationId: '',
|
||||
metadataJson: '{}'
|
||||
}
|
||||
}
|
||||
if (kind === 'recordCost') {
|
||||
return {
|
||||
subscriptionId: String(subscription.id || ''),
|
||||
usageEventId: '',
|
||||
eventType: 'incurred',
|
||||
costCategory: 'ai_inference',
|
||||
quantity: '1',
|
||||
unit: '次',
|
||||
unitCost: '0',
|
||||
originalCurrency: String(subscription.currency || plan.currency || 'CNY'),
|
||||
reportingCurrency: String(subscription.currency || plan.currency || 'CNY'),
|
||||
fxRate: '1',
|
||||
provider: '',
|
||||
sku: '',
|
||||
modelName: '',
|
||||
allocationKey: '',
|
||||
occurredAt: current,
|
||||
sourceSystem: 'x-financial-web',
|
||||
idempotencyKey: createIdempotencyKey('cost'),
|
||||
reversalOfCostEventId: '',
|
||||
correlationId: '',
|
||||
metadataJson: '{}'
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === 'transitionSubscription') {
|
||||
const allowedTargets = resolveSubscriptionTransitionTargets(subscription.status)
|
||||
return {
|
||||
resourceId: String(subscription.id || ''),
|
||||
expectedVersion: Number(subscription.version || 1),
|
||||
currentStatus: String(subscription.status || ''),
|
||||
targetStatus: allowedTargets[0] || '',
|
||||
reason: ''
|
||||
}
|
||||
}
|
||||
|
||||
const currentResource = kind === 'activatePlan'
|
||||
? plan
|
||||
: kind === 'activateSubscription'
|
||||
? subscription
|
||||
: entitlement
|
||||
return {
|
||||
resourceId: String(currentResource.id || ''),
|
||||
expectedVersion: Number(currentResource.version || 1),
|
||||
reason: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSubscriptionTransitionTargets(status) {
|
||||
const allowed = {
|
||||
trialing: ['past_due', 'suspended', 'canceled', 'expired'],
|
||||
active: ['past_due', 'suspended', 'canceled', 'expired'],
|
||||
past_due: ['suspended', 'canceled', 'expired'],
|
||||
suspended: ['canceled', 'expired'],
|
||||
canceled: [],
|
||||
expired: []
|
||||
}
|
||||
return allowed[String(status || '')] || ['past_due', 'suspended', 'canceled', 'expired']
|
||||
}
|
||||
|
||||
export function commercialActionMeta(kind) {
|
||||
return COMMERCIAL_ACTIONS[kind] || null
|
||||
}
|
||||
|
||||
export function buildCommercialConfirmationRows(kind, payload = {}) {
|
||||
if (kind === 'createPlan') {
|
||||
return [
|
||||
`套餐:${payload.name}(${payload.planCode})`,
|
||||
`计价:${payload.pricingModel} / ${payload.billingInterval}`,
|
||||
`基础费用:${payload.currency} ${payload.baseFee}`,
|
||||
'创建后为草稿,仍需单独激活才会退役同编码旧版本。'
|
||||
]
|
||||
}
|
||||
if (kind === 'createSubscription') {
|
||||
return [
|
||||
`订阅键:${payload.subscriptionKey}`,
|
||||
`套餐 ID:${payload.planId}`,
|
||||
`席位数:${payload.seats}`,
|
||||
'订阅快照会固定当时的价格和币种。'
|
||||
]
|
||||
}
|
||||
if (kind === 'upsertEntitlement') {
|
||||
return [
|
||||
`权益:${payload.entitlementKey}`,
|
||||
`指标:${payload.metricKey}(${payload.unit})`,
|
||||
`超额策略:${payload.overagePolicy}`,
|
||||
'已有用量后,影响计费口径的字段不可被覆盖修改。'
|
||||
]
|
||||
}
|
||||
if (kind.startsWith('activate')) {
|
||||
return [
|
||||
`资源 ID:${payload.resourceId}`,
|
||||
`期望版本:v${payload.expectedVersion}`,
|
||||
'若版本已变化,服务器会拒绝本次操作并要求刷新。'
|
||||
]
|
||||
}
|
||||
if (kind === 'transitionSubscription') {
|
||||
return [
|
||||
`订阅 ID:${payload.resourceId}`,
|
||||
`期望版本:v${payload.expectedVersion}`,
|
||||
`目标状态:${payload.targetStatus}`,
|
||||
`变更原因:${payload.reason}`,
|
||||
'暂停、取消或过期会使商业能力失败关闭;恢复需单独执行重新激活。'
|
||||
]
|
||||
}
|
||||
if (kind === 'recordUsage') {
|
||||
return [
|
||||
`权益 ID:${payload.entitlementId}`,
|
||||
`事件:${payload.eventType} ${payload.quantity}`,
|
||||
`幂等键:${payload.idempotencyKey}`,
|
||||
'用量事件写入后不可编辑;纠错必须新增 credit、adjustment 或 reversal。'
|
||||
]
|
||||
}
|
||||
return [
|
||||
`成本:${payload.costCategory} · ${payload.quantity} ${payload.unit}`,
|
||||
`原币:${payload.originalCurrency},报告币:${payload.reportingCurrency},汇率:${payload.fxRate}`,
|
||||
`幂等键:${payload.idempotencyKey}`,
|
||||
'成本事件写入后不可编辑;不同币种不会在界面中隐式合并。'
|
||||
]
|
||||
}
|
||||
570
web/src/components/commercial/commercialWorkspaceModel.js
Normal file
570
web/src/components/commercial/commercialWorkspaceModel.js
Normal file
@@ -0,0 +1,570 @@
|
||||
const STATUS_LABELS = {
|
||||
complete: '完整',
|
||||
available: '可用',
|
||||
partial: '部分可用',
|
||||
unavailable: '不可用',
|
||||
active: '生效中',
|
||||
trialing: '试用中',
|
||||
past_due: '逾期待处理',
|
||||
suspended: '已暂停',
|
||||
canceled: '已取消',
|
||||
expired: '已过期',
|
||||
draft: '草稿',
|
||||
retired: '已退役',
|
||||
approaching: '接近配额',
|
||||
exhausted: '配额耗尽',
|
||||
unlimited: '不限量',
|
||||
inactive: '当前不可用',
|
||||
feasible: '区间可行',
|
||||
insufficient_value: '价值不足',
|
||||
cost_only: '仅成本可用'
|
||||
}
|
||||
|
||||
const PRICING_MODEL_LABELS = {
|
||||
hybrid: '混合定价(基础订阅 + 封顶成功费)',
|
||||
subscription: '订阅制',
|
||||
pilot_collecting: '试点采集证据',
|
||||
optimize_unit_economics: '先优化单位经济性'
|
||||
}
|
||||
|
||||
export const COMMERCIAL_ACTIONS = Object.freeze({
|
||||
createPlan: { label: '创建套餐版本', family: 'configuration', confirm: '确认创建套餐版本' },
|
||||
activatePlan: { label: '激活套餐版本', family: 'activation', confirm: '确认激活套餐版本' },
|
||||
createSubscription: { label: '创建订阅快照', family: 'configuration', confirm: '确认创建并启用订阅' },
|
||||
activateSubscription: { label: '重新激活订阅', family: 'activation', confirm: '确认重新激活订阅' },
|
||||
transitionSubscription: { label: '变更订阅状态', family: 'activation', confirm: '确认变更订阅状态' },
|
||||
upsertEntitlement: { label: '配置权益版本', family: 'configuration', confirm: '确认保存权益版本' },
|
||||
activateEntitlement: { label: '激活权益版本', family: 'activation', confirm: '确认激活权益版本' },
|
||||
recordUsage: { label: '记录用量事件', family: 'metering', confirm: '确认写入不可变用量事件' },
|
||||
recordCost: { label: '记录成本事件', family: 'metering', confirm: '确认写入不可变成本事件' }
|
||||
})
|
||||
|
||||
export function statusLabel(status) {
|
||||
const normalized = String(status || '').trim()
|
||||
return STATUS_LABELS[normalized] || normalized || '未知'
|
||||
}
|
||||
|
||||
export function classifyCommercialAccountState({ loading = false, error = null, account = null } = {}) {
|
||||
if (loading && !account) return 'loading'
|
||||
if (error?.status === 401 || error?.status === 403) return 'permission'
|
||||
if (error && !account) return 'error'
|
||||
if (!account || account.dataStatus === 'unavailable') return 'unavailable'
|
||||
if (account.dataStatus === 'partial') return 'partial'
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
function normalizeCurrency(value) {
|
||||
const currency = String(value || '').trim().toUpperCase()
|
||||
return /^[A-Z]{3}$/u.test(currency) ? currency : ''
|
||||
}
|
||||
|
||||
function finiteNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export function formatCurrencyAmount(value, currency, locale = 'zh-CN') {
|
||||
const amount = finiteNumber(value)
|
||||
const normalizedCurrency = normalizeCurrency(currency)
|
||||
if (amount === null || !normalizedCurrency) return '—'
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: normalizedCurrency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 4
|
||||
}).format(amount)
|
||||
} catch {
|
||||
return `${normalizedCurrency} ${amount.toLocaleString(locale)}`
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRatio(value, locale = 'zh-CN') {
|
||||
const ratio = finiteNumber(value)
|
||||
if (ratio === null) return '—'
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 2
|
||||
}).format(ratio)
|
||||
}
|
||||
|
||||
export function formatMetricValues(metric = {}) {
|
||||
if (metric.status === 'unavailable') return []
|
||||
return (Array.isArray(metric.values) ? metric.values : [])
|
||||
.map((item) => ({
|
||||
currency: normalizeCurrency(item.currency),
|
||||
amount: finiteNumber(item.amount),
|
||||
displayValue: formatCurrencyAmount(item.amount, item.currency),
|
||||
basis: String(item.basis || '')
|
||||
}))
|
||||
.filter((item) => item.currency && item.amount !== null)
|
||||
}
|
||||
|
||||
export function formatMetricRatios(metric = {}) {
|
||||
if (metric.status === 'unavailable') return []
|
||||
return (Array.isArray(metric.ratios) ? metric.ratios : [])
|
||||
.map((item) => ({
|
||||
currency: normalizeCurrency(item.currency),
|
||||
ratio: finiteNumber(item.ratio),
|
||||
displayValue: formatRatio(item.ratio),
|
||||
numeratorLabel: formatCurrencyAmount(item.numerator, item.currency),
|
||||
denominatorLabel: formatCurrencyAmount(item.denominator, item.currency)
|
||||
}))
|
||||
.filter((item) => item.currency && item.ratio !== null)
|
||||
}
|
||||
|
||||
function metricCard(metric, category) {
|
||||
const normalized = metric && typeof metric === 'object' ? metric : {}
|
||||
const values = formatMetricValues(normalized)
|
||||
const ratios = formatMetricRatios(normalized)
|
||||
const status = ['available', 'partial', 'unavailable'].includes(normalized.status)
|
||||
? normalized.status
|
||||
: 'unavailable'
|
||||
return {
|
||||
key: String(normalized.key || category),
|
||||
label: String(normalized.label || '数据不可用'),
|
||||
category,
|
||||
status,
|
||||
statusLabel: statusLabel(status),
|
||||
values,
|
||||
ratios,
|
||||
displayValue: ratios[0]?.displayValue || values[0]?.displayValue || '不可用',
|
||||
multiCurrency: new Set([...values, ...ratios].map((item) => item.currency)).size > 1,
|
||||
reason: String(normalized.reason || '后端未返回该指标的事实口径。'),
|
||||
requiredInputs: Array.isArray(normalized.requiredInputs) ? normalized.requiredInputs : [],
|
||||
notes: Array.isArray(normalized.notes) ? normalized.notes : []
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCommercialValueLedgers(analytics = null) {
|
||||
if (!analytics) {
|
||||
return { customer: [], platform: [] }
|
||||
}
|
||||
return {
|
||||
customer: [
|
||||
metricCard(analytics.verifiedCashSavings, 'verifiedCashSavings'),
|
||||
metricCard(analytics.customerCharges, 'customerCharges'),
|
||||
metricCard(analytics.customerRoi, 'customerRoi'),
|
||||
metricCard(analytics.customerLaborValue, 'customerLaborValue')
|
||||
],
|
||||
platform: [
|
||||
metricCard(analytics.internalCosts, 'internalCosts'),
|
||||
metricCard(analytics.contributionMargin, 'contributionMargin')
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function buildQuotaRows(account = null) {
|
||||
return (Array.isArray(account?.quotas) ? account.quotas : []).map((quota) => {
|
||||
const entitlement = quota.entitlement || {}
|
||||
const used = finiteNumber(quota.usedQuantity)
|
||||
const hardRemaining = finiteNumber(quota.hardLimitRemaining)
|
||||
return {
|
||||
id: String(entitlement.id || ''),
|
||||
key: String(entitlement.entitlementKey || entitlement.metricKey || ''),
|
||||
metricKey: String(entitlement.metricKey || ''),
|
||||
unit: String(entitlement.unit || '次'),
|
||||
type: String(entitlement.entitlementType || ''),
|
||||
status: String(quota.status || 'inactive'),
|
||||
statusLabel: statusLabel(quota.status),
|
||||
usedLabel: used === null ? '不可用' : used.toLocaleString('zh-CN'),
|
||||
remainingLabel: quota.status === 'unlimited'
|
||||
? '不限量'
|
||||
: hardRemaining === null
|
||||
? '未配置硬上限'
|
||||
: hardRemaining.toLocaleString('zh-CN'),
|
||||
commerciallyAllowed: quota.commerciallyAllowed === true,
|
||||
reason: String(quota.reason || ''),
|
||||
version: Number(entitlement.version || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createDefaultCommercialRange(now = new Date()) {
|
||||
const end = new Date(now)
|
||||
const start = new Date(now)
|
||||
start.setUTCDate(start.getUTCDate() - 90)
|
||||
const toInputDate = (value) => value.toISOString().slice(0, 10)
|
||||
return { start: toInputDate(start), end: toInputDate(end) }
|
||||
}
|
||||
|
||||
export function buildAnalyticsWindow(range = {}) {
|
||||
const startText = String(range.start || '').trim()
|
||||
const endText = String(range.end || '').trim()
|
||||
if (!startText || !endText) throw new Error('请选择完整的分析开始与结束日期。')
|
||||
const start = new Date(`${startText}T00:00:00`)
|
||||
const end = new Date(`${endText}T23:59:59.999`)
|
||||
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || start >= end) {
|
||||
throw new Error('分析开始日期必须早于结束日期。')
|
||||
}
|
||||
if (end.getTime() - start.getTime() > 731 * 86_400_000) {
|
||||
throw new Error('单次商业分析时间范围不能超过 731 天。')
|
||||
}
|
||||
return { start: start.toISOString(), end: end.toISOString(), asOf: new Date().toISOString() }
|
||||
}
|
||||
|
||||
function toLocalDateTimeInput(value) {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (!Number.isFinite(date.getTime())) return ''
|
||||
const pad = (item) => String(item).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
export function createDefaultPricingScenarioForm(range = {}, now = new Date()) {
|
||||
const fallback = createDefaultCommercialRange(now)
|
||||
return {
|
||||
start: String(range.start || fallback.start),
|
||||
end: String(range.end || fallback.end),
|
||||
asOf: toLocalDateTimeInput(now),
|
||||
targetContributionMarginPercent: '65',
|
||||
maxVerifiedSavingsSharePercent: '25'
|
||||
}
|
||||
}
|
||||
|
||||
function percentageRate(value, label, { allowZero = false, upperExclusive = false } = {}) {
|
||||
const percent = Number(value)
|
||||
const validLower = allowZero ? percent >= 0 : percent > 0
|
||||
const roundedRate = Number((percent / 100).toFixed(6))
|
||||
const validUpper = upperExclusive ? roundedRate < 0.95 : roundedRate <= 1
|
||||
if (!Number.isFinite(percent) || !validLower || !validUpper) {
|
||||
const range = upperExclusive ? '大于等于 0 且小于 95' : '大于 0 且不超过 100'
|
||||
throw new Error(`${label}必须${range}。`)
|
||||
}
|
||||
return roundedRate.toFixed(6).replace(/(?:\.0+|(?<decimal>\.\d*?)0+)$/u, '$<decimal>')
|
||||
}
|
||||
|
||||
export function buildPricingScenarioPayload(form = {}) {
|
||||
const window = buildAnalyticsWindow({ start: form.start, end: form.end })
|
||||
const asOf = toExplicitTimezone(form.asOf, '证据截止时间')
|
||||
if (new Date(asOf).getTime() < new Date(window.start).getTime()) {
|
||||
throw new Error('证据截止时间不能早于定价分析开始时间。')
|
||||
}
|
||||
return {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
asOf,
|
||||
targetContributionMarginRate: percentageRate(
|
||||
form.targetContributionMarginPercent,
|
||||
'目标贡献毛利率',
|
||||
{ allowZero: true, upperExclusive: true }
|
||||
),
|
||||
maxVerifiedSavingsShare: percentageRate(
|
||||
form.maxVerifiedSavingsSharePercent,
|
||||
'最大价值分享比例'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function pricingMoneyLabel(value, currency) {
|
||||
return value === null || value === undefined
|
||||
? '不可用'
|
||||
: formatCurrencyAmount(value, currency)
|
||||
}
|
||||
|
||||
export function buildPricingScenarioView(result = null) {
|
||||
if (!result || typeof result !== 'object') return null
|
||||
const rows = (Array.isArray(result.scenarios) ? result.scenarios : []).map((item, index) => {
|
||||
const currency = normalizeCurrency(item.currency)
|
||||
const status = ['feasible', 'insufficient_value', 'cost_only', 'unavailable'].includes(item.status)
|
||||
? item.status
|
||||
: 'unavailable'
|
||||
const missingInputs = []
|
||||
if (item.internalCost === null || item.internalCost === undefined) {
|
||||
missingInputs.push('同币种内部成本事实')
|
||||
}
|
||||
if (item.verifiedCashSavings === null || item.verifiedCashSavings === undefined) {
|
||||
missingInputs.push('同币种财务确认现金节省')
|
||||
}
|
||||
return {
|
||||
key: `${currency || 'unknown'}-${index}`,
|
||||
currency: currency || '未知币种',
|
||||
status,
|
||||
statusLabel: statusLabel(status),
|
||||
internalCostLabel: pricingMoneyLabel(item.internalCost, currency),
|
||||
verifiedCashSavingsLabel: pricingMoneyLabel(item.verifiedCashSavings, currency),
|
||||
minimumSustainableChargeLabel: pricingMoneyLabel(item.minimumSustainableCharge, currency),
|
||||
maximumValueAlignedChargeLabel: pricingMoneyLabel(item.maximumValueAlignedCharge, currency),
|
||||
maximumSuccessFeeLabel: pricingMoneyLabel(item.maximumSuccessFee, currency),
|
||||
customerRoiLabel: formatRatio(item.customerRoiAtMinimumCharge),
|
||||
contributionMarginLabel: formatRatio(item.contributionMarginAtValueCeiling),
|
||||
missingInputs,
|
||||
reason: String(item.reason || '后端未返回该币种定价结论。')
|
||||
}
|
||||
})
|
||||
const evidenceStatus = ['complete', 'partial', 'unavailable'].includes(result.evidenceStatus)
|
||||
? result.evidenceStatus
|
||||
: 'unavailable'
|
||||
return {
|
||||
tenantId: String(result.tenantId || ''),
|
||||
start: String(result.start || ''),
|
||||
end: String(result.end || ''),
|
||||
asOf: String(result.asOf || ''),
|
||||
targetMarginLabel: formatRatio(result.targetContributionMarginRate),
|
||||
maxValueShareLabel: formatRatio(result.maxVerifiedSavingsShare),
|
||||
recommendedModel: String(result.recommendedModel || 'pilot_collecting'),
|
||||
recommendedModelLabel: PRICING_MODEL_LABELS[result.recommendedModel] || '继续采集证据',
|
||||
evidenceStatus,
|
||||
evidenceStatusLabel: statusLabel(evidenceStatus),
|
||||
rows,
|
||||
multiCurrency: new Set(rows.map((item) => item.currency)).size > 1,
|
||||
notes: Array.isArray(result.notes) ? result.notes.map((item) => String(item)) : []
|
||||
}
|
||||
}
|
||||
|
||||
export function toExplicitTimezone(value, fieldLabel) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) throw new Error(`请填写${fieldLabel}。`)
|
||||
const parsed = new Date(normalized)
|
||||
if (!Number.isFinite(parsed.getTime())) throw new Error(`${fieldLabel}格式无效。`)
|
||||
return parsed.toISOString()
|
||||
}
|
||||
|
||||
function requiredText(form, key, label) {
|
||||
const value = String(form?.[key] ?? '').trim()
|
||||
if (!value) throw new Error(`请填写${label}。`)
|
||||
return value
|
||||
}
|
||||
|
||||
function positiveInteger(value, label, minimum = 1) {
|
||||
const number = Number(value)
|
||||
if (!Number.isInteger(number) || number < minimum) {
|
||||
throw new Error(`${label}必须是不小于 ${minimum} 的整数。`)
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value, label, optional = false) {
|
||||
if (optional && (value === '' || value === null || value === undefined)) return null
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number) || number < 0) throw new Error(`${label}必须是非负数。`)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function positiveNumber(value, label) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number) || number <= 0) throw new Error(`${label}必须大于 0。`)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function ensureAscendingWindow(start, end, label) {
|
||||
if (end && new Date(end).getTime() <= new Date(start).getTime()) {
|
||||
throw new Error(`${label}结束时间必须晚于开始时间。`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(value, label) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return {}
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(normalized)
|
||||
} catch {
|
||||
throw new Error(`${label}必须是有效 JSON。`)
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${label}必须是 JSON 对象。`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function optionalText(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function createPlanPayload(form) {
|
||||
const currency = normalizeCurrency(requiredText(form, 'currency', '套餐币种'))
|
||||
if (!currency) throw new Error('套餐币种必须是三位英文字母。')
|
||||
const payload = {
|
||||
planCode: requiredText(form, 'planCode', '套餐编码'),
|
||||
name: requiredText(form, 'name', '套餐名称'),
|
||||
pricingModel: requiredText(form, 'pricingModel', '计价模式'),
|
||||
billingInterval: requiredText(form, 'billingInterval', '计费周期'),
|
||||
currency,
|
||||
baseFee: nonNegativeNumber(form.baseFee, '基础费用'),
|
||||
includedSeats: positiveInteger(form.includedSeats, '包含席位', 0),
|
||||
overageEnabled: Boolean(form.overageEnabled),
|
||||
effectiveFrom: toExplicitTimezone(form.effectiveFrom, '套餐生效时间'),
|
||||
effectiveTo: form.effectiveTo ? toExplicitTimezone(form.effectiveTo, '套餐失效时间') : null,
|
||||
contractTermsJson: parseJsonObject(form.contractTermsJson, '合同条款')
|
||||
}
|
||||
ensureAscendingWindow(payload.effectiveFrom, payload.effectiveTo, '套餐生效窗口')
|
||||
return payload
|
||||
}
|
||||
|
||||
function createSubscriptionPayload(form, account) {
|
||||
const provider = optionalText(form.externalProvider)
|
||||
const externalId = optionalText(form.externalSubscriptionId)
|
||||
if (Boolean(provider) !== Boolean(externalId)) {
|
||||
throw new Error('外部订阅提供商和订阅编号必须同时填写或同时留空。')
|
||||
}
|
||||
const payload = {
|
||||
subscriptionKey: requiredText(form, 'subscriptionKey', '订阅键'),
|
||||
planId: requiredText({ planId: form.planId || account?.plan?.id }, 'planId', '套餐 ID'),
|
||||
status: String(form.status || 'active'),
|
||||
startsAt: toExplicitTimezone(form.startsAt, '订阅开始时间'),
|
||||
endsAt: form.endsAt ? toExplicitTimezone(form.endsAt, '订阅结束时间') : null,
|
||||
currentPeriodStart: toExplicitTimezone(form.currentPeriodStart, '当前周期开始时间'),
|
||||
currentPeriodEnd: toExplicitTimezone(form.currentPeriodEnd, '当前周期结束时间'),
|
||||
seats: positiveInteger(form.seats, '席位数'),
|
||||
autoRenew: Boolean(form.autoRenew),
|
||||
externalProvider: provider,
|
||||
externalSubscriptionId: externalId,
|
||||
metadataJson: parseJsonObject(form.metadataJson, '订阅元数据')
|
||||
}
|
||||
ensureAscendingWindow(payload.startsAt, payload.endsAt, '订阅')
|
||||
ensureAscendingWindow(payload.currentPeriodStart, payload.currentPeriodEnd, '当前订阅周期')
|
||||
return payload
|
||||
}
|
||||
|
||||
function entitlementPayload(form, account) {
|
||||
const entitlementType = requiredText(form, 'entitlementType', '权益类型')
|
||||
let includedQuantity = nonNegativeNumber(form.includedQuantity, '包含量', true)
|
||||
let hardLimitQuantity = nonNegativeNumber(form.hardLimitQuantity, '硬配额', true)
|
||||
if (entitlementType === 'unlimited') {
|
||||
includedQuantity = null
|
||||
hardLimitQuantity = null
|
||||
} else if (entitlementType === 'metered' && includedQuantity === null) {
|
||||
throw new Error('计量权益必须填写包含量。')
|
||||
}
|
||||
if (entitlementType === 'feature') {
|
||||
const allowed = new Set([null, '0', '1'])
|
||||
if (!allowed.has(includedQuantity) || !allowed.has(hardLimitQuantity)) {
|
||||
throw new Error('功能权益的包含量和硬配额只能是 0 或 1。')
|
||||
}
|
||||
}
|
||||
if (includedQuantity !== null && hardLimitQuantity !== null && Number(hardLimitQuantity) < Number(includedQuantity)) {
|
||||
throw new Error('硬配额不能小于包含量。')
|
||||
}
|
||||
const payload = {
|
||||
subscriptionId: requiredText(
|
||||
{ subscriptionId: form.subscriptionId || account?.subscription?.id },
|
||||
'subscriptionId',
|
||||
'订阅 ID'
|
||||
),
|
||||
entitlementKey: requiredText(form, 'entitlementKey', '权益键'),
|
||||
metricKey: requiredText(form, 'metricKey', '计量指标键'),
|
||||
entitlementType,
|
||||
unit: requiredText(form, 'unit', '计量单位'),
|
||||
includedQuantity,
|
||||
hardLimitQuantity,
|
||||
resetInterval: requiredText(form, 'resetInterval', '重置周期'),
|
||||
overagePolicy: requiredText(form, 'overagePolicy', '超额策略'),
|
||||
status: String(form.status || 'active'),
|
||||
effectiveFrom: toExplicitTimezone(form.effectiveFrom, '权益生效时间'),
|
||||
effectiveTo: form.effectiveTo ? toExplicitTimezone(form.effectiveTo, '权益失效时间') : null,
|
||||
configJson: parseJsonObject(form.configJson, '权益配置')
|
||||
}
|
||||
ensureAscendingWindow(payload.effectiveFrom, payload.effectiveTo, '权益生效窗口')
|
||||
return payload
|
||||
}
|
||||
|
||||
function activationPayload(form, account, kind) {
|
||||
const defaults = {
|
||||
activatePlan: account?.plan,
|
||||
activateSubscription: account?.subscription,
|
||||
activateEntitlement: null
|
||||
}
|
||||
const current = defaults[kind]
|
||||
const reason = requiredText(form, 'reason', '激活原因')
|
||||
if (reason.length < 2) throw new Error('激活原因至少填写 2 个字符。')
|
||||
return {
|
||||
resourceId: requiredText({ resourceId: form.resourceId || current?.id }, 'resourceId', '资源 ID'),
|
||||
expectedVersion: positiveInteger(form.expectedVersion || current?.version, '期望版本'),
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
function subscriptionTransitionPayload(form, account) {
|
||||
const activation = activationPayload(form, account, 'activateSubscription')
|
||||
const targetStatus = requiredText(form, 'targetStatus', '目标状态')
|
||||
if (!['past_due', 'suspended', 'canceled', 'expired'].includes(targetStatus)) {
|
||||
throw new Error('目标订阅状态无效。')
|
||||
}
|
||||
const reason = requiredText(form, 'reason', '状态变更原因')
|
||||
if (reason.length < 2) throw new Error('状态变更原因至少填写 2 个字符。')
|
||||
return { ...activation, targetStatus, reason }
|
||||
}
|
||||
|
||||
function usagePayload(form, account) {
|
||||
const eventType = String(form.eventType || 'usage')
|
||||
const quantity = Number(requiredText(form, 'quantity', '用量数量'))
|
||||
if (!Number.isFinite(quantity)) throw new Error('用量数量必须是有效数字。')
|
||||
if (eventType === 'usage' && quantity <= 0) throw new Error('用量事件数量必须大于 0。')
|
||||
if (eventType === 'credit' && quantity >= 0) throw new Error('抵扣事件数量必须小于 0。')
|
||||
if (['adjustment', 'reversal'].includes(eventType) && quantity === 0) throw new Error('调整或冲回事件数量不能为 0。')
|
||||
const reversalOfEventId = optionalText(form.reversalOfEventId)
|
||||
if ((eventType === 'reversal') !== Boolean(reversalOfEventId)) {
|
||||
throw new Error('只有冲回事件必须且只能填写被冲回事件 ID。')
|
||||
}
|
||||
const subjectType = optionalText(form.subjectType)
|
||||
const subjectId = optionalText(form.subjectId)
|
||||
if (Boolean(subjectType) !== Boolean(subjectId)) throw new Error('主体类型和主体 ID 必须同时填写或同时留空。')
|
||||
return {
|
||||
subscriptionId: requiredText(
|
||||
{ subscriptionId: form.subscriptionId || account?.subscription?.id },
|
||||
'subscriptionId',
|
||||
'订阅 ID'
|
||||
),
|
||||
entitlementId: requiredText(form, 'entitlementId', '权益 ID'),
|
||||
eventType,
|
||||
quantity: String(form.quantity),
|
||||
occurredAt: toExplicitTimezone(form.occurredAt, '用量发生时间'),
|
||||
sourceSystem: requiredText(form, 'sourceSystem', '来源系统'),
|
||||
idempotencyKey: requiredText(form, 'idempotencyKey', '幂等键'),
|
||||
reversalOfEventId,
|
||||
subjectType,
|
||||
subjectId,
|
||||
correlationId: optionalText(form.correlationId),
|
||||
metadataJson: parseJsonObject(form.metadataJson, '用量元数据')
|
||||
}
|
||||
}
|
||||
|
||||
function costPayload(form, account) {
|
||||
const originalCurrency = normalizeCurrency(requiredText(form, 'originalCurrency', '原始币种'))
|
||||
const reportingCurrency = normalizeCurrency(requiredText(form, 'reportingCurrency', '报告币种'))
|
||||
if (!originalCurrency || !reportingCurrency) throw new Error('成本币种必须是三位英文字母。')
|
||||
const subscriptionId = optionalText(form.subscriptionId || account?.subscription?.id)
|
||||
const usageEventId = optionalText(form.usageEventId)
|
||||
if (usageEventId && !subscriptionId) throw new Error('关联用量事件时必须填写订阅 ID。')
|
||||
const eventType = String(form.eventType || 'incurred')
|
||||
const reversalOfCostEventId = optionalText(form.reversalOfCostEventId)
|
||||
if ((eventType === 'reversal') !== Boolean(reversalOfCostEventId)) {
|
||||
throw new Error('只有冲回成本事件必须且只能填写被冲回成本事件 ID。')
|
||||
}
|
||||
return {
|
||||
subscriptionId,
|
||||
usageEventId,
|
||||
eventType,
|
||||
costCategory: requiredText(form, 'costCategory', '成本类别'),
|
||||
quantity: positiveNumber(form.quantity, '成本数量'),
|
||||
unit: requiredText(form, 'unit', '成本单位'),
|
||||
unitCost: nonNegativeNumber(form.unitCost, '单位成本'),
|
||||
originalCurrency,
|
||||
reportingCurrency,
|
||||
fxRate: positiveNumber(form.fxRate, '汇率'),
|
||||
provider: optionalText(form.provider),
|
||||
sku: optionalText(form.sku),
|
||||
modelName: optionalText(form.modelName),
|
||||
allocationKey: requiredText(form, 'allocationKey', '归集键'),
|
||||
occurredAt: toExplicitTimezone(form.occurredAt, '成本发生时间'),
|
||||
sourceSystem: requiredText(form, 'sourceSystem', '来源系统'),
|
||||
idempotencyKey: requiredText(form, 'idempotencyKey', '幂等键'),
|
||||
reversalOfCostEventId,
|
||||
correlationId: optionalText(form.correlationId),
|
||||
metadataJson: parseJsonObject(form.metadataJson, '成本元数据')
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCommercialMutation(kind, form = {}, account = null) {
|
||||
if (!COMMERCIAL_ACTIONS[kind]) throw new Error('不支持的商业管理操作。')
|
||||
if (kind === 'createPlan') return createPlanPayload(form)
|
||||
if (kind === 'createSubscription') return createSubscriptionPayload(form, account)
|
||||
if (kind === 'upsertEntitlement') return entitlementPayload(form, account)
|
||||
if (kind === 'transitionSubscription') return subscriptionTransitionPayload(form, account)
|
||||
if (kind.startsWith('activate')) return activationPayload(form, account, kind)
|
||||
if (kind === 'recordUsage') return usagePayload(form, account)
|
||||
return costPayload(form, account)
|
||||
}
|
||||
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>
|
||||
@@ -4,6 +4,8 @@ import { formatDateValue } from '../../utils/dateRangeDefaults.js'
|
||||
|
||||
const OVERVIEW_DASHBOARD_OPTIONS = [
|
||||
{ label: '财务看板', value: 'finance' },
|
||||
{ label: '经营价值看板', value: 'value' },
|
||||
{ label: '商业化管理', value: 'commercial' },
|
||||
{ label: '风险看板', value: 'risk' },
|
||||
{ label: '数字员工看板', value: 'digitalEmployee' },
|
||||
{ label: '系统看板', value: 'system' }
|
||||
|
||||
111
web/src/components/travel/TravelRequestApplicationFacts.vue
Normal file
111
web/src/components/travel/TravelRequestApplicationFacts.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="application-detail-facts">
|
||||
<div
|
||||
v-for="item in factItems"
|
||||
:key="item.key"
|
||||
class="application-detail-fact"
|
||||
:class="{
|
||||
highlight: item.highlight,
|
||||
emphasis: item.emphasis,
|
||||
editable: canEditItem(item),
|
||||
editing: isEditingItem(item)
|
||||
}"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>
|
||||
<template v-if="isEditingItem(item)">
|
||||
<ElDatePicker
|
||||
v-if="resolveEditorControl(item) === 'date'"
|
||||
:model-value="editor.draftValue"
|
||||
class="application-detail-editor-control"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
format="YYYY/MM/DD"
|
||||
popper-class="detail-editor-date-popper"
|
||||
:clearable="false"
|
||||
:disabled="editor.saving"
|
||||
@update:model-value="emit('update-draft', $event)"
|
||||
@click.stop
|
||||
/>
|
||||
<EnterpriseSelect
|
||||
v-else-if="resolveEditorControl(item) === 'select'"
|
||||
:model-value="editor.draftValue"
|
||||
class="application-detail-editor-control application-detail-editor-select"
|
||||
:options="transportModeOptions"
|
||||
clearable
|
||||
:teleported="false"
|
||||
:disabled="editor.saving"
|
||||
@update:model-value="emit('update-draft', $event)"
|
||||
@click.stop
|
||||
/>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="editor.draftValue"
|
||||
class="application-detail-editor-control"
|
||||
clearable
|
||||
:disabled="editor.saving"
|
||||
@update:model-value="emit('update-draft', $event)"
|
||||
@click.stop
|
||||
@keydown.enter.stop.prevent="emit('save', item)"
|
||||
@keydown.esc.stop.prevent="emit('cancel')"
|
||||
/>
|
||||
<button
|
||||
class="application-detail-edit-confirm"
|
||||
type="button"
|
||||
title="保存"
|
||||
aria-label="保存"
|
||||
:disabled="editor.saving"
|
||||
@click.stop="emit('save', item)"
|
||||
>
|
||||
<i :class="editor.saving ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-check'"></i>
|
||||
</button>
|
||||
<button
|
||||
class="application-detail-edit-cancel"
|
||||
type="button"
|
||||
title="取消"
|
||||
aria-label="取消"
|
||||
:disabled="editor.saving"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="application-detail-fact-value">{{ item.value }}</span>
|
||||
<button
|
||||
v-if="canEditItem(item)"
|
||||
class="application-detail-edit-btn"
|
||||
type="button"
|
||||
title="编辑"
|
||||
aria-label="编辑"
|
||||
:disabled="actionBusy"
|
||||
@click.stop="emit('open', item)"
|
||||
>
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
</button>
|
||||
</template>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElDatePicker } from 'element-plus/es/components/date-picker/index.mjs'
|
||||
import { ElInput } from 'element-plus/es/components/input/index.mjs'
|
||||
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
|
||||
defineProps({
|
||||
factItems: { type: Array, default: () => [] },
|
||||
editor: { type: Object, required: true },
|
||||
transportModeOptions: { type: Array, default: () => [] },
|
||||
actionBusy: { type: Boolean, default: false },
|
||||
canEditItem: { type: Function, required: true },
|
||||
isEditingItem: { type: Function, required: true },
|
||||
resolveEditorControl: { type: Function, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-draft', 'save', 'cancel', 'open'])
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/travel-request-application-facts.css"></style>
|
||||
Reference in New Issue
Block a user