feat(platform): close AI expense value loop

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

View File

@@ -0,0 +1,424 @@
.cfo-value-dashboard {
min-width: 0;
display: grid;
gap: 16px;
padding-bottom: 16px;
}
.value-dashboard-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 20px;
border-top: 3px solid var(--theme-primary);
}
.value-eyebrow {
display: block;
margin-bottom: 5px;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
letter-spacing: .1em;
text-transform: uppercase;
}
.value-dashboard-header h2 {
margin: 0;
color: var(--ink);
font-size: 21px;
line-height: 1.3;
}
.value-dashboard-header p,
.value-filter-head p,
.value-card-head p {
margin: 5px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.value-source-summary {
min-width: 220px;
display: grid;
grid-template-columns: auto auto;
align-items: center;
justify-content: end;
gap: 6px 12px;
}
.value-source-summary small {
grid-column: 1;
color: var(--muted);
font-size: 12px;
text-align: right;
}
.value-source-summary .btn {
grid-column: 2;
grid-row: 1 / span 3;
min-width: 96px;
min-height: 44px;
}
.value-source-status {
min-height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 9px;
border-radius: 999px;
background: var(--success-soft);
color: var(--success);
font-size: 12px;
font-weight: 800;
}
.value-source-status.partial,
.value-source-status.stale { background: var(--warning-soft); color: var(--warning); }
.value-source-status.error,
.value-source-status.permission { background: var(--danger-soft); color: var(--danger); }
.value-source-status.empty,
.value-source-status.loading { background: var(--info-soft); color: var(--info); }
.value-filter-panel { padding: 16px 18px; }
.value-filter-head,
.value-card-head,
.value-filter-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.value-filter-head h3,
.value-card-head h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
line-height: 1.35;
}
.value-filter-toggle {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font-size: 12px;
font-weight: 750;
}
.value-filter-toggle span {
min-width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--theme-primary);
color: var(--theme-primary-contrast);
font-size: 12px;
}
.value-filter-form { display: grid; gap: 12px; margin-top: 14px; }
.value-filter-primary,
.value-filter-advanced {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.value-filter-advanced {
grid-template-columns: repeat(5, minmax(0, 1fr));
padding-top: 12px;
border-top: 1px dashed var(--line);
}
.value-filter-form label,
.value-compact-select {
min-width: 0;
display: grid;
gap: 6px;
color: var(--text);
font-size: 12px;
font-weight: 750;
}
.value-filter-form label > span em {
margin-left: 4px;
color: var(--muted);
font-size: 12px;
font-style: normal;
font-weight: 500;
}
.value-filter-form input,
.value-filter-form select,
.value-compact-select select {
width: 100%;
min-height: 44px;
padding: 8px 10px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: 13px;
}
.value-filter-actions {
justify-content: flex-end;
padding-top: 2px;
}
.value-filter-actions .btn { min-width: 112px; }
.btn:disabled { cursor: not-allowed; opacity: .5; transform: none; box-shadow: none; }
.value-feedback {
min-height: 44px;
display: flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border: 1px solid var(--line);
border-left-width: 4px;
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font-size: 12px;
line-height: 1.5;
}
.value-feedback.success { border-color: var(--success-line); border-left-color: var(--success); background: var(--success-soft); color: var(--success-active); }
.value-feedback.warning,
.value-feedback.stale { border-color: var(--warning-line); border-left-color: var(--warning); background: var(--warning-soft); color: var(--warning-active); }
.value-feedback.error { border-color: var(--danger-line); border-left-color: var(--danger); background: var(--danger-soft); color: var(--danger-active); }
.value-page-state {
min-height: 320px;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
padding: 32px;
text-align: center;
}
.value-page-state > i { color: var(--line-strong); font-size: 40px; }
.value-page-state.loading > i { color: var(--theme-primary); }
.value-page-state.error > i,
.value-page-state.permission > i { color: var(--danger); }
.value-page-state strong { color: var(--ink); font-size: 17px; }
.value-page-state span { max-width: 560px; color: var(--muted); font-size: 13px; line-height: 1.65; }
.value-page-state .btn { margin-top: 8px; }
.value-kpi-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.value-kpi-card {
min-width: 0;
min-height: 188px;
display: flex;
flex-direction: column;
padding: 17px;
border-top: 3px solid var(--theme-primary);
}
.value-kpi-card.available { border-color: var(--success); }
.value-kpi-card.zero { border-color: var(--info); }
.value-kpi-card.baseline-missing { border-color: var(--warning); }
.value-kpi-card.unavailable { border-color: var(--line-strong); }
.value-kpi-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.value-kpi-head span { min-width: 0; color: var(--muted); font-size: 12px; font-weight: 750; }
.value-kpi-head em {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-style: normal;
font-weight: 850;
white-space: nowrap;
}
.value-kpi-card.zero .value-kpi-head em { background: var(--info-soft); color: var(--info); }
.value-kpi-card.baseline-missing .value-kpi-head em { background: var(--warning-soft); color: var(--warning); }
.value-kpi-card.unavailable .value-kpi-head em { background: var(--info-soft); color: var(--info); }
.value-kpi-card > strong { margin-top: 20px; color: var(--ink); font-size: clamp(22px, 2vw, 30px); line-height: 1.1; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.value-kpi-card > p { margin: 8px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.value-kpi-card details { margin-top: auto; padding-top: 14px; }
.value-kpi-card summary { min-height: 44px; display: inline-flex; align-items: center; color: var(--theme-primary-active); font-size: 12px; font-weight: 750; cursor: pointer; }
.value-kpi-card details p,
.value-kpi-card details ul { margin: 6px 0 0; padding-left: 18px; color: var(--muted); font-size: 12px; line-height: 1.6; }
.value-dashboard-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px;
}
.value-dashboard-card { min-width: 0; padding: 18px; }
.value-funnel-card { grid-column: span 5; }
.value-trend-card { grid-column: span 7; }
.value-breakdown-card { grid-column: span 7; }
.value-guardrail-card { grid-column: span 5; }
.value-quality-card { grid-column: span 12; }
.value-card-head { margin-bottom: 16px; }
.value-compact-select { min-width: 96px; }
.value-compact-select select { min-height: 44px; }
.value-funnel-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.value-funnel-list li { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 6px 12px; }
.value-funnel-list li > div { display: flex; justify-content: space-between; gap: 12px; }
.value-funnel-list span,
.value-funnel-list strong,
.value-funnel-list small { font-size: 12px; }
.value-funnel-list span { color: var(--text); font-weight: 700; }
.value-funnel-list strong { color: var(--ink); }
.value-funnel-list small { grid-column: 2; grid-row: 1 / span 2; align-self: center; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.value-funnel-list i { height: 8px; overflow: hidden; border-radius: 999px; background: var(--info-soft); }
.value-funnel-list b { display: block; height: 100%; border-radius: inherit; background: var(--theme-gradient-primary); }
.value-realization-rates { display: grid; gap: 5px; margin-top: 16px; padding: 11px; border-radius: var(--radius); background: var(--surface-soft); }
.value-realization-rates span { color: var(--muted); font-size: 12px; }
.value-realization-rates strong { color: var(--ink); }
.value-dimension-tabs { display: flex; gap: 7px; margin-bottom: 14px; overflow-x: auto; padding-bottom: 4px; }
.value-dimension-tabs button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font-size: 12px;
font-weight: 750;
white-space: nowrap;
}
.value-dimension-tabs button.active { border-color: var(--theme-primary); background: var(--theme-primary-soft); color: var(--theme-primary-active); }
.value-breakdown-list { display: grid; gap: 12px; }
.value-breakdown-row { display: grid; grid-template-columns: minmax(100px, .9fr) minmax(120px, 1fr) minmax(130px, 1.1fr); align-items: center; gap: 12px; }
.value-breakdown-row div:last-child { text-align: right; }
.value-breakdown-row strong,
.value-breakdown-row span { display: block; overflow-wrap: anywhere; }
.value-breakdown-row strong { color: var(--ink); font-size: 12px; }
.value-breakdown-row span { margin-top: 3px; color: var(--muted); font-size: 12px; }
.value-breakdown-row > i { height: 8px; overflow: hidden; border-radius: 999px; background: var(--info-soft); }
.value-breakdown-row b { display: block; height: 100%; border-radius: inherit; background: var(--success); }
.value-guardrail-list { display: grid; gap: 10px; }
.value-guardrail-row { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.value-guardrail-row > i { width: 32px; height: 32px; display: grid; place-items: center; border-radius: var(--radius); background: var(--success-soft); color: var(--success); font-size: 17px; }
.value-guardrail-row.attention > i { background: var(--warning-soft); color: var(--warning); }
.value-guardrail-row.unavailable > i { background: var(--info-soft); color: var(--info); }
.value-guardrail-row strong,
.value-guardrail-row span { display: block; }
.value-guardrail-row strong { color: var(--ink); font-size: 12px; }
.value-guardrail-row span { margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.45; }
.value-guardrail-row em { color: var(--muted); font-size: 12px; font-style: normal; font-weight: 800; white-space: nowrap; }
.value-quality-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
.value-quality-grid > div { min-height: 76px; display: grid; place-content: center; justify-items: center; gap: 4px; padding: 9px; border: 1px solid var(--success-line); border-radius: var(--radius); background: var(--success-soft); text-align: center; }
.value-quality-grid > div.attention { border-color: var(--warning-line); background: var(--warning-soft); }
.value-quality-grid > div.unavailable { border-color: var(--info-line); background: var(--info-soft); }
.value-quality-grid strong { color: var(--ink); font-size: 20px; font-variant-numeric: tabular-nums; }
.value-quality-grid span { color: var(--muted); font-size: 12px; line-height: 1.4; }
.value-source-chip { min-height: 28px; 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; white-space: nowrap; }
.value-coverage-notes { margin: 14px 0 0; padding: 12px 12px 12px 28px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; line-height: 1.6; }
.value-inline-state { min-height: 160px; display: grid; place-content: center; color: var(--muted); font-size: 12px; text-align: center; }
.value-opportunity-panel { min-width: 0; padding: 18px; }
.value-list-loading { color: var(--theme-primary-active); font-size: 12px; }
.value-opportunity-list { display: grid; border-top: 1px solid var(--line); }
.value-opportunity-row { min-width: 0; display: grid; grid-template-columns: minmax(220px, 1.5fr) minmax(150px, .9fr) minmax(130px, .8fr) auto; align-items: center; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--line); }
.value-opportunity-main { min-width: 0; display: flex; align-items: center; gap: 10px; }
.value-status-pill { min-height: 24px; display: inline-flex; align-items: center; flex: 0 0 auto; 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-opportunity-main div,
.value-opportunity-amount,
.value-opportunity-owner { min-width: 0; }
.value-opportunity-main strong,
.value-opportunity-main small,
.value-opportunity-amount strong,
.value-opportunity-amount span,
.value-opportunity-owner strong,
.value-opportunity-owner span { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.value-opportunity-main strong,
.value-opportunity-amount strong,
.value-opportunity-owner strong { color: var(--ink); font-size: 12px; }
.value-opportunity-main small,
.value-opportunity-amount span,
.value-opportunity-owner span { margin-top: 4px; color: var(--muted); font-size: 12px; }
.value-opportunity-amount strong { font-size: 14px; font-variant-numeric: tabular-nums; }
.value-opportunity-row > .btn { min-width: 104px; min-height: 44px; font-size: 12px; }
.value-list-state { min-height: 160px; display: flex; align-items: center; justify-content: center; gap: 12px; color: var(--muted); font-size: 12px; text-align: center; }
.value-list-state.error { color: var(--danger-active); }
.value-pagination { justify-content: flex-end; margin-top: 16px; }
.value-pagination :deep(.el-pager li),
.value-pagination :deep(.btn-prev),
.value-pagination :deep(.btn-next) { min-width: 44px; height: 44px; }
@media (max-width: 1120px) {
.value-filter-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.value-filter-advanced { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.value-funnel-card,
.value-trend-card,
.value-breakdown-card,
.value-guardrail-card { grid-column: span 12; }
.value-quality-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.value-opportunity-row { grid-template-columns: minmax(220px, 1.4fr) minmax(150px, 1fr) auto; }
.value-opportunity-owner { display: none; }
}
@media (max-width: 760px) {
.value-dashboard-header,
.value-filter-head,
.value-card-head { flex-direction: column; align-items: stretch; }
.value-source-summary { width: 100%; grid-template-columns: 1fr auto; justify-content: stretch; }
.value-source-summary small { text-align: left; }
.value-kpi-grid { grid-template-columns: 1fr; }
.value-filter-primary,
.value-filter-advanced { grid-template-columns: 1fr; }
.value-filter-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.value-filter-actions .btn { width: 100%; min-width: 0; }
.value-quality-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.value-opportunity-row { grid-template-columns: 1fr; align-items: stretch; gap: 10px; padding: 16px 0; }
.value-opportunity-owner { display: block; }
.value-opportunity-main { align-items: flex-start; }
.value-opportunity-main strong,
.value-opportunity-main small,
.value-opportunity-amount strong,
.value-opportunity-amount span,
.value-opportunity-owner strong,
.value-opportunity-owner span { white-space: normal; }
.value-opportunity-row > .btn { width: 100%; }
.value-breakdown-row { grid-template-columns: 1fr; gap: 7px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.value-breakdown-row div:last-child { text-align: left; }
.value-funnel-list li { grid-template-columns: minmax(0, 1fr) 100px; }
}
@media (max-width: 420px) {
.value-dashboard-header,
.value-filter-panel,
.value-dashboard-card,
.value-opportunity-panel { padding: 14px; }
.value-quality-grid { grid-template-columns: 1fr; }
.value-guardrail-row { grid-template-columns: 32px minmax(0, 1fr); }
.value-guardrail-row em { grid-column: 2; }
}

View File

@@ -0,0 +1,149 @@
.application-detail-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 4px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.application-detail-fact {
display: grid;
grid-template-columns: minmax(96px, 28%) minmax(0, 1fr);
min-height: 48px;
border-top: 1px solid #edf2f7;
border-left: 1px solid #edf2f7;
}
.application-detail-fact:nth-child(-n + 2) {
border-top: 0;
}
.application-detail-fact:nth-child(2n + 1) {
border-left: 0;
}
.application-detail-fact > span,
.application-detail-fact > strong {
display: flex;
align-items: center;
min-width: 0;
padding: 11px 14px;
line-height: 1.5;
}
.application-detail-fact > span {
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.application-detail-fact strong {
border-left: 1px solid #edf2f7;
color: #0f172a;
font-size: 13px;
font-weight: 750;
gap: 8px;
overflow-wrap: anywhere;
}
.application-detail-fact.highlight > span {
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.application-detail-fact.highlight strong {
background: color-mix(in srgb, var(--theme-primary-soft) 55%, #ffffff);
}
.application-detail-fact.emphasis strong {
color: var(--theme-primary-active);
font-weight: 850;
}
.application-detail-fact-value {
min-width: 0;
flex: 1 1 auto;
overflow-wrap: anywhere;
}
.application-detail-edit-btn,
.application-detail-edit-confirm,
.application-detail-edit-cancel {
flex: 0 0 auto;
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.application-detail-edit-btn {
opacity: 0;
transition:
opacity 0.16s ease,
background 0.16s ease,
color 0.16s ease;
}
.application-detail-fact.editable:hover .application-detail-edit-btn,
.application-detail-edit-btn:focus-visible {
opacity: 1;
}
.application-detail-edit-btn:hover:not(:disabled),
.application-detail-edit-btn:focus-visible,
.application-detail-edit-confirm:hover:not(:disabled),
.application-detail-edit-cancel:hover:not(:disabled) {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active);
}
.application-detail-edit-confirm {
background: rgba(22, 163, 74, 0.1);
color: #15803d;
}
.application-detail-edit-cancel {
background: #f1f5f9;
}
.application-detail-edit-btn:disabled,
.application-detail-edit-confirm:disabled,
.application-detail-edit-cancel:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.application-detail-fact.editing strong {
align-items: center;
}
.application-detail-editor-control {
flex: 1 1 auto;
min-width: 0;
}
.application-detail-editor-select {
width: 100%;
}
@media (max-width: 760px) {
.application-detail-facts {
grid-template-columns: 1fr;
}
.application-detail-fact {
border-left: 0;
}
.application-detail-fact:nth-child(2) {
border-top: 1px solid #edf2f7;
}
}

View File

@@ -17,6 +17,54 @@
overflow: hidden;
}
.budget-list-head {
display: grid;
gap: 8px;
}
.budget-list-head .budget-configuration-focus {
margin-bottom: 0;
}
.budget-configuration-focus {
min-height: 48px;
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
padding: 9px 12px;
border: 1px solid rgba(var(--theme-primary-rgb), .24);
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.budget-configuration-focus > i {
margin-top: 1px;
font-size: 18px;
}
.budget-configuration-focus strong,
.budget-configuration-focus span {
display: block;
}
.budget-configuration-focus strong {
font-size: 13px;
}
.budget-configuration-focus span {
margin-top: 3px;
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.budget-detail-table tr.is-source-focus td {
background: var(--warning-soft);
box-shadow: inset 0 1px 0 rgba(245, 158, 11, .22), inset 0 -1px 0 rgba(245, 158, 11, .22);
}
.budget-scope-tabs small {
min-width: 22px;
height: 18px;

View File

@@ -835,18 +835,6 @@
white-space: normal;
}
.application-detail-facts {
grid-template-columns: 1fr;
}
.application-detail-fact {
border-left: 0;
}
.application-detail-fact:nth-child(2) {
border-top: 1px solid #edf2f7;
}
.detail-card {
padding: 14px 16px;
}

View File

@@ -590,142 +590,6 @@
white-space: pre-wrap;
}
.application-detail-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 4px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.application-detail-fact {
display: grid;
grid-template-columns: minmax(96px, 28%) minmax(0, 1fr);
min-height: 48px;
border-top: 1px solid #edf2f7;
border-left: 1px solid #edf2f7;
}
.application-detail-fact:nth-child(-n + 2) {
border-top: 0;
}
.application-detail-fact:nth-child(2n + 1) {
border-left: 0;
}
.application-detail-fact > span,
.application-detail-fact > strong {
display: flex;
align-items: center;
min-width: 0;
padding: 11px 14px;
line-height: 1.5;
}
.application-detail-fact > span {
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.application-detail-fact strong {
border-left: 1px solid #edf2f7;
color: #0f172a;
font-size: 13px;
font-weight: 750;
gap: 8px;
overflow-wrap: anywhere;
}
.application-detail-fact.highlight > span {
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.application-detail-fact.highlight strong {
background: color-mix(in srgb, var(--theme-primary-soft) 55%, #ffffff);
}
.application-detail-fact.emphasis strong {
color: var(--theme-primary-active);
font-weight: 850;
}
.application-detail-fact-value {
min-width: 0;
flex: 1 1 auto;
overflow-wrap: anywhere;
}
.application-detail-edit-btn,
.application-detail-edit-confirm,
.application-detail-edit-cancel {
flex: 0 0 auto;
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.application-detail-edit-btn {
opacity: 0;
transition:
opacity 0.16s ease,
background 0.16s ease,
color 0.16s ease;
}
.application-detail-fact.editable:hover .application-detail-edit-btn,
.application-detail-edit-btn:focus-visible {
opacity: 1;
}
.application-detail-edit-btn:hover:not(:disabled),
.application-detail-edit-btn:focus-visible,
.application-detail-edit-confirm:hover:not(:disabled),
.application-detail-edit-cancel:hover:not(:disabled) {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active);
}
.application-detail-edit-confirm {
background: rgba(22, 163, 74, 0.1);
color: #15803d;
}
.application-detail-edit-cancel {
background: #f1f5f9;
}
.application-detail-edit-btn:disabled,
.application-detail-edit-confirm:disabled,
.application-detail-edit-cancel:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.application-detail-fact.editing strong {
align-items: center;
}
.application-detail-editor-control {
flex: 1 1 auto;
min-width: 0;
}
.application-detail-editor-select {
width: 100%;
}
.related-application-facts {
margin-top: 0;
}

View File

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

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

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

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

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

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

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

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

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

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

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

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

View 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}`,
'成本事件写入后不可编辑;不同币种不会在界面中隐式合并。'
]
}

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

View File

@@ -0,0 +1,169 @@
<template>
<el-dialog
:model-value="open"
class="cfo-value-action-dialog"
width="min(92vw, 560px)"
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="actionMeta.label"
@update:model-value="$emit('update:open', $event)"
>
<form class="value-action-form" @submit.prevent="submit">
<div class="value-action-context">
<span>{{ contextLabel }}</span>
<strong>{{ targetTitle }}</strong>
<small>当前版本 v{{ Number(target?.version || 0) }}</small>
</div>
<label v-if="action === 'reverse'" class="value-action-field">
<span>冲回金额 <em aria-hidden="true">*</em></span>
<input
v-model="form.reversalAmount"
type="number"
min="0.01"
step="0.01"
inputmode="decimal"
autocomplete="off"
/>
</label>
<label class="value-action-field">
<span>操作说明 <em aria-hidden="true">*</em></span>
<textarea
v-model.trim="form.comment"
rows="4"
maxlength="1000"
:placeholder="commentPlaceholder"
></textarea>
<small>至少 2 个字符说明将写入不可变事件记录</small>
</label>
<p v-if="validationError || error" class="value-action-error" role="alert">
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
{{ validationError || error?.message || '操作失败,请刷新后重试。' }}
</p>
</form>
<template #footer>
<div class="value-action-footer">
<button type="button" class="btn ghost" :disabled="submitting" @click="$emit('update:open', false)">
取消
</button>
<button
type="button"
class="btn"
:class="actionMeta.tone === 'danger' ? 'danger' : 'primary'"
:disabled="submitting"
@click="submit"
>
<i v-if="submitting" class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
{{ submitting ? '正在写入证据链' : actionMeta.label }}
</button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { createRequestId, resolveActionMeta } from '../../views/scripts/cfoValueDashboardModel.js'
const props = defineProps({
open: { type: Boolean, default: false },
action: { type: String, default: '' },
target: { type: Object, default: () => null },
opportunity: { type: Object, default: () => null },
submitting: { type: Boolean, default: false },
error: { type: Object, default: () => null }
})
const emit = defineEmits(['update:open', 'submit'])
const form = reactive(createForm())
const validationError = ref('')
const actionMeta = computed(() => {
const meta = resolveActionMeta(props.action)
if (props.target?.realizationType || props.target?.realizationKey) {
return { ...meta, kind: 'realization' }
}
return meta
})
const targetTitle = computed(() => (
props.opportunity?.title
|| props.target?.title
|| props.opportunity?.claimNoSnapshot
|| props.target?.id
|| '节省机会'
))
const contextLabel = computed(() => (
actionMeta.value.kind === 'realization' ? '实际结果' : '节省机会'
))
const commentPlaceholder = computed(() => {
if (props.action === 'confirm') return '请说明核验依据或外部凭证来源'
if (props.action === 'reverse') return '请说明冲回原因及对应事实'
if (props.action === 'reject') return '请说明拒绝原因'
return '请说明本次操作依据'
})
watch(
() => [props.open, props.action, props.target?.id],
([open]) => {
if (!open) return
Object.assign(form, createForm(props.target, props.opportunity))
validationError.value = ''
},
{ immediate: true }
)
function submit() {
validationError.value = validate()
if (validationError.value || props.submitting) return
emit('submit', {
kind: actionMeta.value.kind,
action: props.action,
requestId: form.requestId,
opportunityId: props.opportunity?.id || props.target?.id,
realizationId: actionMeta.value.kind === 'realization' ? props.target?.id : undefined,
expectedVersion: Number(props.target?.version || 0),
comment: form.comment,
...(props.action === 'reverse' ? { reversalAmount: form.reversalAmount } : {})
})
}
function validate() {
if (String(form.comment || '').trim().length < 2) return '请填写至少 2 个字符的操作说明。'
if (Number(props.target?.version || 0) < 1) return '当前记录缺少有效版本,请刷新详情后重试。'
if (props.action === 'reverse' && Number(form.reversalAmount) <= 0) return '冲回金额必须大于 0。'
return ''
}
function createForm() {
return {
requestId: createRequestId('cfo-value-action'),
comment: '',
reversalAmount: ''
}
}
</script>
<style scoped>
.value-action-form { display: grid; gap: 18px; }
.value-action-context { display: grid; gap: 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.value-action-context span, .value-action-context small { color: var(--muted); font-size: 12px; }
.value-action-context strong { color: var(--ink); font-size: 14px; }
.value-action-field { display: grid; gap: 7px; color: var(--text); font-size: 13px; font-weight: 700; }
.value-action-field em { color: var(--danger); font-style: normal; }
.value-action-field input, .value-action-field textarea { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--surface); color: var(--ink); }
.value-action-field textarea { resize: vertical; line-height: 1.55; }
.value-action-field small { color: var(--muted); font-size: 12px; font-weight: 500; line-height: 1.55; }
.value-action-error { display: flex; align-items: flex-start; gap: 8px; color: var(--danger-active); font-size: 13px; line-height: 1.5; }
.value-action-footer { display: flex; justify-content: flex-end; gap: 10px; }
.value-action-footer .btn { min-width: 112px; min-height: 44px; }
.btn:disabled { cursor: not-allowed; opacity: .48; transform: none; box-shadow: none; }
@media (max-width: 560px) {
.value-action-footer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.value-action-footer .btn { width: 100%; min-width: 0; }
}
</style>

View File

@@ -0,0 +1,393 @@
<template>
<section class="cfo-value-dashboard" aria-labelledby="cfo-value-title">
<header class="panel value-dashboard-header">
<div>
<span class="value-eyebrow">Evidence-backed value</span>
<h2 id="cfo-value-title">经营价值与节省闭环</h2>
<p>只把有基线证据canonical 去重和独立财务确认的实际结果计入现金节省</p>
</div>
<div class="value-source-summary">
<span :class="['value-source-status', dashboardState]">
<i class="mdi mdi-database-check-outline" aria-hidden="true"></i>
{{ sourceStatusLabel }}
</span>
<small>{{ windowLabel }}</small>
<small>{{ freshnessLabel }}</small>
<button type="button" class="btn" :disabled="dashboardLoading" @click="reloadAll">
<i :class="dashboardLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'" aria-hidden="true"></i>
{{ dashboardLoading ? '同步中' : '刷新' }}
</button>
</div>
</header>
<section class="panel value-filter-panel" aria-labelledby="value-filter-title">
<div class="value-filter-head">
<div>
<h3 id="value-filter-title">筛选经营价值</h3>
<p>筛选条件会写入当前 URL刷新和返回后仍可恢复</p>
</div>
<button
type="button"
class="value-filter-toggle"
:aria-expanded="advancedFiltersOpen ? 'true' : 'false'"
aria-controls="value-advanced-filters"
@click="advancedFiltersOpen = !advancedFiltersOpen"
>
<i class="mdi mdi-tune-variant" aria-hidden="true"></i>
高级筛选
<span v-if="activeFilterCount">{{ activeFilterCount }}</span>
</button>
</div>
<form class="value-filter-form" @submit.prevent="applyFilters">
<div class="value-filter-primary">
<label>
<span>价值类型</span>
<select v-model="filterDraft.valueKind">
<option value="">全部类型</option>
<option value="cash">现金节省</option>
<option value="labor">工时价值</option>
</select>
</label>
<label>
<span>机会状态 <em>仅筛选下方台账</em></span>
<select v-model="filterDraft.status">
<option value="">全部状态</option>
<option v-for="option in statusOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</label>
<label>
<span>来源类型</span>
<input v-model.trim="filterDraft.sourceType" maxlength="50" placeholder="例如 standard_adjustment" autocomplete="off" />
</label>
<label>
<span>负责人 ID</span>
<input v-model.trim="filterDraft.ownerId" maxlength="120" placeholder="输入负责人 ID" autocomplete="off" />
</label>
</div>
<div v-if="advancedFiltersOpen" id="value-advanced-filters" class="value-filter-advanced">
<label><span>部门 ID</span><input v-model.trim="filterDraft.departmentId" maxlength="160" autocomplete="off" /></label>
<label><span>项目编码</span><input v-model.trim="filterDraft.projectCode" maxlength="160" autocomplete="off" /></label>
<label><span>费用类型</span><input v-model.trim="filterDraft.expenseType" maxlength="80" autocomplete="off" /></label>
<label><span>供应商 ID</span><input v-model.trim="filterDraft.supplierId" maxlength="160" autocomplete="off" /></label>
<label><span>城市</span><input v-model.trim="filterDraft.city" maxlength="160" autocomplete="off" /></label>
</div>
<div class="value-filter-actions">
<button type="button" class="btn ghost" :disabled="dashboardLoading" @click="resetFilters">重置</button>
<button type="submit" class="btn primary" :disabled="dashboardLoading">
<i class="mdi mdi-filter-check-outline" aria-hidden="true"></i>
应用筛选
</button>
</div>
</form>
</section>
<div v-if="mutationMessage" class="value-feedback success" role="status" aria-live="polite">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
{{ mutationMessage }}
</div>
<div v-if="dashboardState === 'loading'" class="panel value-page-state loading" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
<strong>正在核对价值台账与财务确认记录</strong>
<span>加载期间不会展示旧的演示数字</span>
</div>
<div v-else-if="dashboardState === 'permission'" class="panel value-page-state permission" role="alert">
<i class="mdi mdi-shield-lock-outline" aria-hidden="true"></i>
<strong>当前账号无权查看企业经营价值</strong>
<span>{{ dashboardError?.message || '该看板仅向财务、管理层和授权平台管理员开放。' }}</span>
</div>
<div v-else-if="dashboardState === 'error'" class="panel value-page-state error" role="alert">
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
<strong>经营价值数据加载失败</strong>
<span>{{ dashboardError?.message || '请检查网络后重试。' }}</span>
<button type="button" class="btn" @click="reloadAll">重新加载</button>
</div>
<div v-else-if="dashboardState === 'empty'" class="panel value-page-state empty" role="status">
<i class="mdi mdi-database-off-outline" aria-hidden="true"></i>
<strong>当前范围还没有价值台账记录</strong>
<span>这不是零节省系统尚无可用于判断的机会或实际结果可调整顶部时间范围或清空筛选</span>
<button v-if="activeFilterCount" type="button" class="btn" @click="resetFilters">清空筛选</button>
</div>
<template v-else>
<div v-if="dashboardState === 'partial'" class="value-feedback warning" role="status">
<i class="mdi mdi-alert-outline" aria-hidden="true"></i>
当前结果为部分覆盖存在证据汇率或去重待补项相关记录不会进入已核验主指标
</div>
<div v-if="dashboardStale" class="value-feedback stale" role="status">
<i class="mdi mdi-clock-alert-outline" aria-hidden="true"></i>
数据源超过 24 小时未产生新事实页面保留最后一次真实结果并明确标记为陈旧
</div>
<div v-if="dashboardError" class="value-feedback error" role="alert">
<i class="mdi mdi-cloud-alert-outline" aria-hidden="true"></i>
最新同步失败当前展示的是上一次成功读取的数据{{ dashboardError.message }}
</div>
<div class="value-kpi-grid">
<article v-for="kpi in kpis" :key="kpi.key" :class="['panel', 'value-kpi-card', kpi.state]">
<div class="value-kpi-head">
<span>{{ kpi.label }}</span>
<em>{{ kpi.statusLabel }}</em>
</div>
<strong>{{ kpi.displayValue }}</strong>
<p>{{ kpi.detail }}</p>
<details v-if="kpi.requiredInputs?.length || kpi.definition">
<summary>{{ kpi.requiredInputs?.length ? '查看缺失输入' : '查看口径' }}</summary>
<ul v-if="kpi.requiredInputs?.length">
<li v-for="item in kpi.requiredInputs" :key="item">{{ item }}</li>
</ul>
<p v-else>{{ kpi.definition }}</p>
</details>
</article>
</div>
<section class="value-dashboard-grid">
<article class="panel value-dashboard-card value-funnel-card">
<div class="value-card-head">
<div><h3>价值转化漏斗</h3><p>预计执行实际确认与冲回严格分层</p></div>
</div>
<ol class="value-funnel-list">
<li v-for="stage in funnelRows" :key="stage.key">
<div><span>{{ stage.label }}</span><strong>{{ stage.count }} </strong></div>
<i aria-hidden="true"><b :style="{ width: stage.width }"></b></i>
<small>{{ stage.valueLabel }}</small>
</li>
</ol>
<div v-if="realizationRates.length" class="value-realization-rates">
<span v-for="item in realizationRates" :key="item.currency">
{{ item.currency }} 成熟机会实现率 <strong>{{ item.label }}</strong>
</span>
</div>
</article>
<article class="panel value-dashboard-card value-trend-card">
<div class="value-card-head">
<div><h3>实际价值趋势</h3><p>按实际发生月份和币种展示不跨币种相加</p></div>
<label v-if="trend.currencies.length > 1" class="value-compact-select">
<span>币种</span>
<select v-model="trendCurrency">
<option v-for="currency in trend.currencies" :key="currency" :value="currency">{{ currency }}</option>
</select>
</label>
</div>
<CfoValueTrendChart :rows="trend.rows" :currency="trend.selectedCurrency" />
</article>
<article class="panel value-dashboard-card value-breakdown-card">
<div class="value-card-head">
<div><h3>价值归因分解</h3><p>按业务维度钻取预计价值与财务确认价值</p></div>
</div>
<div class="value-dimension-tabs" role="group" aria-label="价值分解维度">
<button
v-for="group in breakdownGroups"
:key="group.dimension"
type="button"
:aria-pressed="activeBreakdown.dimension === group.dimension ? 'true' : 'false'"
:class="{ active: activeBreakdown.dimension === group.dimension }"
@click="activeBreakdownDimension = group.dimension"
>{{ group.label }}</button>
</div>
<div v-if="activeBreakdown.items.length" class="value-breakdown-list">
<div v-for="item in activeBreakdown.items" :key="item.rowKey" class="value-breakdown-row">
<div><strong>{{ item.dimensionName }}</strong><span>{{ item.opportunityCount }} 个机会 · {{ item.currency || '无币种' }}</span></div>
<i aria-hidden="true"><b :style="{ width: item.width }"></b></i>
<div><strong>{{ item.verifiedLabel }}</strong><span>预计 {{ item.estimatedLabel }}</span></div>
</div>
</div>
<p v-else class="value-inline-state">该维度暂无可归因记录</p>
</article>
<article class="panel value-dashboard-card value-guardrail-card">
<div class="value-card-head">
<div><h3>价值护栏</h3><p>避免误把风险暴露重复记录或未确认结果算成节省</p></div>
</div>
<div class="value-guardrail-list">
<div v-for="item in guardrailRows" :key="item.key" :class="['value-guardrail-row', item.status]">
<i :class="item.status === 'ok' ? 'mdi mdi-check-circle-outline' : (item.status === 'attention' ? 'mdi mdi-alert-outline' : 'mdi mdi-minus-circle-outline')" aria-hidden="true"></i>
<div><strong>{{ item.label }}</strong><span>{{ item.reason || [item.countLabel, item.valueLabel, item.rateLabel].filter((value) => value && value !== '—').join(' · ') }}</span></div>
<em>{{ item.statusLabel }}</em>
</div>
</div>
</article>
<article class="panel value-dashboard-card value-quality-card">
<div class="value-card-head">
<div><h3>数据质量与覆盖</h3><p>每个缺口都明确展示不使用默认值掩盖</p></div>
<span class="value-source-chip">Savings Ledger {{ dashboard?.source?.ledgerRevision || '' }}</span>
</div>
<div class="value-quality-grid">
<div v-for="item in dataQualityRows" :key="item.key" :class="item.tone">
<strong>{{ item.displayValue }}</strong><span>{{ item.label }}</span>
</div>
</div>
<ul v-if="qualityNotes.length || coverageNotes.length" class="value-coverage-notes">
<li v-for="note in [...qualityNotes, ...coverageNotes]" :key="note">{{ note }}</li>
</ul>
</article>
</section>
<section class="panel value-opportunity-panel" aria-labelledby="value-opportunities-title">
<div class="value-card-head">
<div>
<h3 id="value-opportunities-title">节省机会台账</h3>
<p> {{ Number(opportunities.total || 0) }} 进入详情可查看基线证据实际结果和不可变事件</p>
</div>
<span v-if="opportunitiesLoading" class="value-list-loading" role="status"><i class="mdi mdi-loading mdi-spin"></i>同步中</span>
</div>
<div v-if="opportunitiesLoading && !opportunities.generatedAt" class="value-list-state" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
<span>正在加载节省机会台账</span>
</div>
<div v-else-if="opportunitiesError" class="value-list-state error" role="alert">
<span>{{ opportunitiesError.message || '机会台账加载失败。' }}</span>
<button type="button" class="btn" @click="loadOpportunities">重试</button>
</div>
<div v-else-if="opportunityRows.length" class="value-opportunity-list">
<article v-for="item in opportunityRows" :key="item.id" class="value-opportunity-row">
<div class="value-opportunity-main">
<span :class="['value-status-pill', item.statusMeta.tone]">{{ item.statusMeta.label }}</span>
<div><strong>{{ item.title }}</strong><small>{{ item.claimNoSnapshot }} · {{ item.category }} · {{ item.sourceType }}</small></div>
</div>
<div class="value-opportunity-amount"><strong>{{ item.estimatedLabel }}</strong><span>预计净节省 · 置信度 {{ item.confidenceLabel }}</span></div>
<div class="value-opportunity-owner"><strong>{{ item.ownerLabel }}</strong><span>更新 {{ item.updatedLabel }}</span></div>
<button type="button" class="btn" @click="openOpportunity(item.id)">查看证据链</button>
</article>
</div>
<div v-else class="value-list-state" role="status">当前筛选条件下暂无节省机会</div>
<el-pagination
v-if="Number(opportunities.totalPages || 0) > 1"
class="value-pagination"
background
layout="prev, pager, next"
:current-page="page"
:page-count="Number(opportunities.totalPages || 0)"
@current-change="setPage"
/>
</section>
</template>
<CfoValueOpportunityDrawer
:open="detailOpen"
:opportunity="selectedOpportunity"
:loading="selectedOpportunityLoading"
:error="selectedOpportunityError"
@update:open="handleDetailOpenChange"
@retry="openOpportunity(selectedOpportunityId)"
@action="openActionDialog"
/>
<CfoValueActionDialog
v-model:open="actionDialogOpen"
:action="actionContext.action"
:target="actionContext.target"
:opportunity="actionContext.opportunity"
:submitting="mutationLoading"
:error="mutationError"
@submit="handleSubmitAction"
/>
</section>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import CfoValueTrendChart from '../charts/CfoValueTrendChart.vue'
import CfoValueActionDialog from './CfoValueActionDialog.vue'
import CfoValueOpportunityDrawer from './CfoValueOpportunityDrawer.vue'
import { useCfoValueDashboard } from '../../composables/useCfoValueDashboard.js'
import { formatDateTime, formatPercent } from '../../views/scripts/cfoValueDashboardModel.js'
const props = defineProps({
activeRange: { type: String, default: '近10日' },
customRange: { type: Object, default: () => ({ start: '', end: '' }) }
})
const advancedFiltersOpen = ref(false)
const actionDialogOpen = ref(false)
const actionContext = reactive({ action: '', target: null, opportunity: null })
const statusOptions = [
['identified', '待评估'], ['accepted', '已接受'], ['in_progress', '执行中'],
['realized', '实际待确认'], ['verified', '财务已确认'], ['reversed', '已冲回'],
['rejected', '已拒绝'], ['expired', '已到期']
].map(([value, label]) => ({ value, label }))
const {
activeBreakdown,
activeBreakdownDimension,
breakdownGroups,
dashboard,
dashboardError,
dashboardLoading,
dashboardState,
dashboardStale,
dataQualityRows,
detailOpen,
filterDraft,
funnelRows,
guardrailRows,
kpis,
mutationError,
mutationLoading,
mutationMessage,
opportunities,
opportunitiesError,
opportunitiesLoading,
opportunityRows,
page,
selectedOpportunity,
selectedOpportunityError,
selectedOpportunityId,
selectedOpportunityLoading,
trend,
trendCurrency,
applyFilters,
closeOpportunity,
loadOpportunities,
openOpportunity,
reloadAll,
resetFilters,
setPage,
submitAction
} = useCfoValueDashboard(props)
const activeFilterCount = computed(() => Object.values(filterDraft).filter((value) => String(value || '').trim()).length)
const coverageNotes = computed(() => dashboard.value?.source?.coverageNotes || [])
const qualityNotes = computed(() => dashboard.value?.dataQuality?.notes || [])
const windowLabel = computed(() => {
const window = dashboard.value?.window
if (!window) return `时间范围:${props.activeRange}`
return `${new Date(window.start).toLocaleDateString('zh-CN')} - ${new Date(window.end).toLocaleDateString('zh-CN')}`
})
const freshnessLabel = computed(() => {
const freshness = dashboard.value?.source?.freshnessAt
return freshness ? `事实更新:${formatDateTime(freshness)}` : '事实更新:暂无记录'
})
const sourceStatusLabel = computed(() => ({
loading: '正在核对', permission: '无访问权限', error: '读取失败', empty: '暂无事实',
partial: '部分覆盖', stale: '数据陈旧', ready: '真实数据'
}[dashboardState.value] || '状态未知'))
const realizationRates = computed(() => Object.entries(dashboard.value?.funnel?.realizationRateByCurrency || {})
.map(([currency, value]) => ({ currency, label: value === null ? '不可计算' : formatPercent(value) })))
function handleDetailOpenChange(open) {
if (!open) closeOpportunity()
}
function openActionDialog(payload) {
actionContext.action = payload.action
actionContext.target = payload.target
actionContext.opportunity = payload.opportunity
mutationError.value = null
actionDialogOpen.value = true
}
async function handleSubmitAction(command) {
try {
await submitAction(command)
actionDialogOpen.value = false
} catch {
// 错误由对话框内的可访问错误区域展示,保留表单便于修正或重试。
}
}
</script>
<style scoped src="../../assets/styles/components/cfo-value-dashboard.css"></style>

View File

@@ -0,0 +1,330 @@
<template>
<el-drawer
:model-value="open"
class="cfo-value-opportunity-drawer"
size="min(96vw, 760px)"
:with-header="false"
@update:model-value="$emit('update:open', $event)"
>
<div class="value-drawer-shell">
<header class="value-drawer-head">
<div>
<span class="value-drawer-eyebrow">节省机会证据链</span>
<h2>{{ opportunity?.title || '节省机会详情' }}</h2>
</div>
<button type="button" class="value-icon-button" aria-label="关闭节省机会详情" @click="$emit('update:open', false)">
<i class="mdi mdi-close" aria-hidden="true"></i>
</button>
</header>
<div v-if="loading" class="value-drawer-state" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
<strong>正在加载基线证据和事件记录</strong>
</div>
<div v-else-if="error" class="value-drawer-state error" role="alert">
<i class="mdi mdi-alert-circle-outline" aria-hidden="true"></i>
<strong>{{ error.message || '节省机会详情加载失败' }}</strong>
<button type="button" class="btn" @click="$emit('retry')">重试</button>
</div>
<div v-else-if="detail" class="value-drawer-content">
<section class="value-detail-hero" aria-labelledby="value-detail-summary-title">
<div>
<span :class="['value-status-pill', detail.statusMeta.tone]">{{ detail.statusMeta.label }}</span>
<span class="value-version-pill">v{{ detail.version }}</span>
</div>
<h3 id="value-detail-summary-title">{{ detail.estimatedLabel }}</h3>
<p>{{ detail.description || '暂无机会说明。' }}</p>
<dl>
<div><dt>单据</dt><dd>{{ detail.claimNoSnapshot || detail.claimId }}</dd></div>
<div><dt>负责人</dt><dd>{{ detail.ownerLabel }}</dd></div>
<div><dt>可信度</dt><dd>{{ detail.confidenceLabel }}</dd></div>
<div><dt>目标日期</dt><dd>{{ detail.dueLabel }}</dd></div>
</dl>
</section>
<section class="value-detail-section" aria-labelledby="value-source-links-title">
<div class="value-section-head">
<h3 id="value-source-links-title">查看来源</h3>
<span>{{ sourceLinks.length }} 个入口</span>
</div>
<nav v-if="sourceLinks.length" class="value-source-link-list" aria-label="节省机会来源入口">
<RouterLink
v-for="link in sourceLinks"
:key="link.key"
:to="link.to"
:class="['value-source-link', link.kind]"
>
<i :class="link.icon" aria-hidden="true"></i>
<span>
<strong>{{ link.label }}</strong>
<small>{{ link.description }}</small>
</span>
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
</RouterLink>
</nav>
<p v-else class="value-inline-empty">当前机会没有可验证的关联单据预算维度或分析维度入口</p>
</section>
<section class="value-detail-section" aria-labelledby="value-actions-title">
<div class="value-section-head">
<h3 id="value-actions-title">可执行动作</h3>
<span v-if="detail.readOnlyReason">只读</span>
</div>
<div v-if="opportunityActions.length" class="value-action-list">
<button
v-for="action in opportunityActions"
:key="action"
type="button"
class="btn"
:class="resolveActionMeta(action).tone === 'danger' ? 'danger' : 'primary'"
@click="$emit('action', { action, target: opportunity, opportunity })"
>
{{ resolveActionMeta(action).label }}
</button>
</div>
<p v-else-if="!requiresExternalEvidence" class="value-inline-empty">
{{ detail.readOnlyReason || '当前状态没有可执行动作' }}
</p>
<p v-if="requiresExternalEvidence" class="value-inline-empty evidence-required" role="status">
实际结果必须来自平台付款事件或关联可追溯的支付银行ERP 凭证当前页面不会提交空证据
外部凭证入口接入前请先完成付款流程或等待连接器回执
</p>
</section>
<section class="value-detail-section" aria-labelledby="value-baseline-title">
<div class="value-section-head">
<h3 id="value-baseline-title">冻结基线</h3>
<span v-if="opportunity.baseline">{{ opportunity.baseline.dataQualityStatus }}</span>
</div>
<dl v-if="opportunity.baseline" class="value-detail-grid">
<div><dt>基线类型</dt><dd>{{ opportunity.baseline.baselineType }}</dd></div>
<div><dt>基线值</dt><dd>{{ formatBaselineValue(opportunity.baseline) }}</dd></div>
<div><dt>样本量</dt><dd>{{ opportunity.baseline.sampleCount }}</dd></div>
<div><dt>质量评分</dt><dd>{{ opportunity.baseline.dataQualityScore }}</dd></div>
<div><dt>算法版本</dt><dd>{{ opportunity.baseline.algorithmVersion }}</dd></div>
<div><dt>政策版本</dt><dd>{{ opportunity.baseline.policyVersion || '未绑定' }}</dd></div>
</dl>
<p v-else class="value-inline-empty baseline-missing">基线详情不可用当前机会不能作为已核验价值依据</p>
</section>
<section class="value-detail-section" aria-labelledby="value-realizations-title">
<div class="value-section-head">
<h3 id="value-realizations-title">实际结果</h3>
<span>{{ realizations.length }} </span>
</div>
<div v-if="realizations.length" class="value-realization-list">
<article v-for="realization in realizations" :key="realization.id" class="value-realization-card">
<div class="value-realization-head">
<div>
<strong>{{ formatMoney(realization.reportingAmount, realization.reportingCurrency) }}</strong>
<span>{{ realization.realizationType === 'reversal' ? '冲回记录' : '实际结果' }} · {{ realizationStatus(realization.status) }}</span>
</div>
<span>v{{ realization.version }}</span>
</div>
<dl>
<div><dt>发生时间</dt><dd>{{ formatDateTime(realization.realizedAt) }}</dd></div>
<div><dt>记录人</dt><dd>{{ realization.recordedByName || realization.recordedById }}</dd></div>
<div><dt>去重状态</dt><dd>{{ realization.dedupeStatus }}</dd></div>
<div><dt>汇率证据</dt><dd>{{ realization.fxSource }} / {{ realization.fxVersion }}</dd></div>
<div><dt>财务确认</dt><dd>{{ financeConfirmationLabel(realization) }}</dd></div>
<div><dt>确认时间</dt><dd>{{ formatDateTime(realization.confirmedAt) }}</dd></div>
</dl>
<p v-if="realization.confirmationNote" class="value-record-note">
确认说明{{ realization.confirmationNote }}
</p>
<p v-else-if="realization.rejectionReason" class="value-record-note">
拒绝原因{{ realization.rejectionReason }}
</p>
<p v-else-if="realization.reversalReason" class="value-record-note">
冲回原因{{ realization.reversalReason }}
</p>
<div v-if="realization.availableActions?.length" class="value-action-list compact">
<button
v-for="action in realization.availableActions"
:key="action"
type="button"
class="btn"
:class="resolveActionMeta(action).tone === 'danger' ? 'danger' : ''"
@click="$emit('action', { action, target: realization, opportunity })"
>
{{ resolveActionMeta(action).label }}
</button>
</div>
<p v-else-if="realization.readOnlyReason" class="value-record-note">{{ realization.readOnlyReason }}</p>
</article>
</div>
<p v-else class="value-inline-empty">尚未记录实际结果预计金额不会进入 CFO 主指标</p>
</section>
<section class="value-detail-section" aria-labelledby="value-evidence-title">
<div class="value-section-head">
<h3 id="value-evidence-title">证据索引</h3>
<span>{{ evidence.length }} </span>
</div>
<ol v-if="evidence.length" class="value-evidence-list">
<li v-for="item in evidence" :key="item.id || item.evidenceKey">
<i class="mdi mdi-file-certificate-outline" aria-hidden="true"></i>
<div>
<strong>{{ item.evidenceRole || item.resourceType }}</strong>
<span>{{ item.sourceSystem }} · {{ item.resourceId }}</span>
<small>{{ item.verificationStatus }} · {{ formatDateTime(item.occurredAt) }}</small>
</div>
</li>
</ol>
<p v-else class="value-inline-empty">暂无独立证据索引请补齐后再确认价值</p>
</section>
<section class="value-detail-section" aria-labelledby="value-events-title">
<div class="value-section-head">
<h3 id="value-events-title">不可变事件记录</h3>
<span>{{ events.length }} </span>
</div>
<ol v-if="events.length" class="value-event-list">
<li v-for="event in events" :key="event.id">
<i aria-hidden="true"></i>
<div>
<strong>{{ event.action }}</strong>
<span>{{ event.actorName || event.actorId }} · v{{ event.expectedVersion }} v{{ event.resultVersion }}</span>
<small>{{ formatDateTime(event.occurredAt) }} · 请求 {{ event.requestId }}</small>
</div>
</li>
</ol>
<p v-else class="value-inline-empty">暂无事件记录</p>
</section>
</div>
</div>
</el-drawer>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import {
buildOpportunityRows,
formatDateTime,
formatMoney,
resolveActionMeta
} from '../../views/scripts/cfoValueDashboardModel.js'
import { buildCfoOpportunitySourceLinks } from '../../views/scripts/cfoValueSourceLinks.js'
const props = defineProps({
open: { type: Boolean, default: false },
opportunity: { type: Object, default: () => null },
loading: { type: Boolean, default: false },
error: { type: Object, default: () => null }
})
defineEmits(['update:open', 'retry', 'action'])
const route = useRoute()
const detail = computed(() => (
props.opportunity ? buildOpportunityRows({ items: [props.opportunity] })[0] : null
))
const sourceLinks = computed(() => buildCfoOpportunitySourceLinks(props.opportunity || {}, {
dashboardQuery: route.query
}))
const realizations = computed(() => props.opportunity?.realizations || [])
const evidence = computed(() => props.opportunity?.evidence || [])
const events = computed(() => props.opportunity?.events || [])
const opportunityActions = computed(() => (
detail.value?.availableActions?.filter((action) => action !== 'record_realization') || []
))
const requiresExternalEvidence = computed(() => (
detail.value?.availableActions?.includes('record_realization') || false
))
function realizationStatus(status) {
return {
pending_confirmation: '待财务确认',
finance_confirmed: '财务已确认',
rejected: '已拒绝',
reversed: '已冲回'
}[status] || status || '未知'
}
function financeConfirmationLabel(realization = {}) {
if (realization.status === 'finance_confirmed') {
return realization.financeConfirmerName || realization.financeConfirmerId || '独立财务已确认'
}
if (realization.status === 'rejected') {
return realization.rejectedByName || realization.rejectedById || '财务已拒绝'
}
if (realization.status === 'reversed') {
return realization.reversedByName || realization.reversedById || '已追加冲回'
}
return '待独立财务确认'
}
function formatBaselineValue(baseline = {}) {
if (baseline.originalCurrency) {
return formatMoney(baseline.baselineValue, baseline.originalCurrency)
}
const unit = String(baseline.unit || '').trim()
return `${Number(baseline.baselineValue || 0).toLocaleString('zh-CN', { maximumFractionDigits: 4 })}${unit ? ` ${unit}` : ''}`
}
</script>
<style scoped>
.value-drawer-shell { min-height: 100%; display: flex; flex-direction: column; background: var(--bg); }
.value-drawer-head { position: sticky; top: 0; z-index: 2; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 22px; border-bottom: 1px solid var(--line); background: rgba(255, 255, 255, .96); backdrop-filter: blur(12px); }
.value-drawer-eyebrow { display: block; color: var(--theme-primary-active); font-size: 12px; font-weight: 800; letter-spacing: .08em; }
.value-drawer-head h2 { margin: 5px 0 0; color: var(--ink); font-size: 20px; line-height: 1.35; }
.value-icon-button { width: 44px; height: 44px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); font-size: 20px; }
.value-drawer-state { min-height: 360px; display: grid; place-content: center; justify-items: center; gap: 14px; padding: 24px; color: var(--muted); text-align: center; }
.value-drawer-state > i { color: var(--theme-primary); font-size: 32px; }
.value-drawer-state.error > i { color: var(--danger); }
.value-drawer-state .btn { min-height: 44px; }
.value-drawer-content { display: grid; gap: 14px; padding: 16px 22px 32px; }
.value-detail-hero, .value-detail-section { padding: 18px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: 0 1px 2px rgba(15, 23, 42, .04); }
.value-detail-hero > div:first-child { display: flex; gap: 8px; }
.value-status-pill, .value-version-pill, .value-section-head > span { min-height: 24px; display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; background: var(--info-soft); color: var(--info); font-size: 12px; font-weight: 800; }
.value-status-pill.success { background: var(--success-soft); color: var(--success); }
.value-status-pill.warning { background: var(--warning-soft); color: var(--warning); }
.value-status-pill.danger { background: var(--danger-soft); color: var(--danger); }
.value-status-pill.primary { background: var(--theme-primary-soft); color: var(--theme-primary-active); }
.value-detail-hero h3 { margin: 16px 0 8px; color: var(--ink); font-size: 30px; font-variant-numeric: tabular-nums; }
.value-detail-hero > p { color: var(--muted); font-size: 13px; line-height: 1.65; }
.value-detail-hero dl, .value-detail-grid, .value-realization-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 18px 0 0; }
dl div { min-width: 0; }
dt { color: var(--muted); font-size: 12px; }
dd { margin: 4px 0 0; color: var(--ink); font-size: 13px; font-weight: 700; overflow-wrap: anywhere; }
.value-section-head, .value-realization-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.value-section-head { margin-bottom: 14px; }
.value-section-head h3 { margin: 0; color: var(--ink); font-size: 15px; }
.value-action-list { display: flex; flex-wrap: wrap; gap: 10px; }
.value-action-list .btn { min-height: 44px; }
.value-action-list.compact { margin-top: 14px; }
.value-action-list.compact .btn { min-height: 44px; font-size: 12px; }
.value-source-link-list { display: grid; gap: 10px; }
.value-source-link { min-height: 64px; display: grid; grid-template-columns: 36px minmax(0, 1fr) 18px; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); color: var(--text); text-decoration: none; transition: border-color 160ms ease, background 160ms ease; }
.value-source-link:hover, .value-source-link:focus-visible { border-color: var(--theme-primary); background: var(--theme-primary-soft); outline: none; }
.value-source-link > i:first-child { width: 36px; height: 36px; display: grid; place-items: center; border-radius: var(--radius); background: var(--surface); color: var(--theme-primary-active); font-size: 18px; }
.value-source-link > span { min-width: 0; }
.value-source-link strong, .value-source-link small { display: block; }
.value-source-link strong { color: var(--ink); font-size: 13px; }
.value-source-link small { margin-top: 4px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.value-source-link > i:last-child { color: var(--muted); }
.value-source-link.budget small { color: var(--warning-active); }
.value-inline-empty, .value-record-note { padding: 12px; border-radius: var(--radius); background: var(--surface-soft); color: var(--muted); font-size: 12px; line-height: 1.55; }
.value-inline-empty.baseline-missing { border-left: 3px solid var(--warning); background: var(--warning-soft); color: var(--warning-active); }
.value-inline-empty.evidence-required { border-left: 3px solid var(--warning); background: var(--warning-soft); color: var(--warning-active); }
.value-realization-list { display: grid; gap: 12px; }
.value-realization-card { padding: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.value-realization-head strong { display: block; color: var(--ink); font-size: 18px; font-variant-numeric: tabular-nums; }
.value-realization-head span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; }
.value-evidence-list, .value-event-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.value-evidence-list li, .value-event-list li { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
.value-evidence-list > li > i { width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto; border-radius: var(--radius); background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 17px; }
.value-evidence-list div, .value-event-list div { min-width: 0; }
.value-evidence-list strong, .value-event-list strong { display: block; color: var(--ink); font-size: 13px; }
.value-evidence-list span, .value-evidence-list small, .value-event-list span, .value-event-list small { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.value-event-list > li > i { width: 10px; height: 10px; margin: 5px 4px 0; flex: 0 0 auto; border: 3px solid var(--theme-primary-soft-strong); border-radius: 50%; background: var(--theme-primary); box-sizing: content-box; }
@media (max-width: 560px) {
.value-drawer-head, .value-drawer-content { padding-left: 14px; padding-right: 14px; }
.value-detail-hero h3 { font-size: 24px; }
.value-detail-hero dl, .value-detail-grid, .value-realization-card dl { grid-template-columns: 1fr; }
.value-action-list { display: grid; grid-template-columns: 1fr; }
.value-action-list .btn { width: 100%; }
}
</style>

View File

@@ -0,0 +1,237 @@
<template>
<section v-if="canView" class="connector-health panel" aria-label="财务连接器运行观测">
<header class="connector-health__head">
<div>
<span class="connector-health__eyebrow">FINANCIAL CONNECTOR</span>
<h3>外部回执与对账健康度</h3>
<p>只展示脱敏运行事实模拟回执和人工付款不会冒充生产现金证明</p>
</div>
<button type="button" :disabled="loading" @click="load">
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
{{ loading ? '刷新中' : '刷新' }}
</button>
</header>
<div v-if="error" class="connector-health__state is-error" role="alert">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ error }}</span>
</div>
<div v-else-if="loading && !payload" class="connector-health__state" role="status">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在读取当前租户连接器指标</span>
</div>
<template v-else>
<div class="connector-health__metrics">
<article v-for="metric in metrics" :key="metric.key" :class="metric.tone">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small :title="metric.detail">{{ metric.detail }}</small>
</article>
</div>
<div class="connector-health__evidence" aria-label="付款证据可信等级说明">
<strong>证据口径</strong>
<span class="is-external"><i class="mdi mdi-shield-check-outline"></i>生产外部回执 · 高可信现金事实</span>
<span class="is-manual"><i class="mdi mdi-account-check-outline"></i>人工付款确认 · 低等级内部状态</span>
<span class="is-simulated"><i class="mdi mdi-flask-outline"></i>模拟/预发布回执 · 仅验证不入核心账</span>
</div>
<div v-if="items.length" class="connector-health__table-wrap">
<table>
<thead>
<tr>
<th>来源</th>
<th>环境 / 证据</th>
<th>状态</th>
<th>最近成功</th>
<th>事件</th>
<th>失败率</th>
<th>积压 / 异常</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.config_id">
<td><strong>{{ item.provider }}</strong><small>{{ item.key_version }}</small></td>
<td><span :class="['evidence-chip', evidenceTone(item)]">{{ item.evidence_label }}</span></td>
<td><span :class="['status-chip', item.status]">{{ statusLabel(item.status) }}</span></td>
<td>{{ formatTime(item.last_success_at) }}</td>
<td>{{ number(item.event_count) }}</td>
<td>{{ percent(item.failure_rate) }}</td>
<td>{{ number(item.backlog_count) }} / {{ number(item.reconciliation_anomaly_count) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="connector-health__state">
<i class="mdi mdi-lan-disconnect"></i>
<span>当前租户尚未配置财务连接器</span>
</div>
<footer v-if="payload">
统计窗口 {{ payload.window_hours || 24 }} 小时 · 更新于 {{ formatTime(payload.generated_at) }}
</footer>
</template>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { fetchFinancialConnectorObservability } from '../../services/financialConnectors.js'
const props = defineProps({
currentUser: { type: Object, default: () => ({}) },
windowHours: { type: Number, default: 24 }
})
const payload = ref(null)
const loading = ref(false)
const error = ref('')
let requestSequence = 0
const roleCodes = computed(() => (
Array.isArray(props.currentUser?.roleCodes)
? props.currentUser.roleCodes.map((item) => String(item || '').trim().toLowerCase())
: []
))
const canView = computed(() => (
Boolean(props.currentUser?.isAdmin)
|| roleCodes.value.includes('finance')
|| roleCodes.value.includes('executive')
))
const summary = computed(() => payload.value?.summary || {})
const items = computed(() => Array.isArray(payload.value?.items) ? payload.value.items : [])
const lastSuccess = computed(() => {
const timestamps = items.value
.map((item) => Date.parse(item.last_success_at || ''))
.filter(Number.isFinite)
return timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : ''
})
const metrics = computed(() => [
{
key: 'last-success',
label: '最后成功',
value: formatTime(lastSuccess.value),
detail: '最近一次已处理连接器事实',
tone: 'is-good'
},
{
key: 'failure-rate',
label: '失败率',
value: percent(summary.value.failure_rate),
detail: `${number(summary.value.failed_event_count)} / ${number(summary.value.event_count)} 个事件`,
tone: Number(summary.value.failed_event_count || 0) ? 'is-warning' : 'is-good'
},
{
key: 'retry',
label: '重试',
value: summary.value.retry_count == null ? '待耐久遥测' : number(summary.value.retry_count),
detail: payload.value?.retry_metric?.reason || '已采集重试次数',
tone: summary.value.retry_count == null ? 'is-neutral' : 'is-good'
},
{
key: 'backlog',
label: '积压',
value: number(summary.value.backlog_count),
detail: '处理中或等待前置事实的事件',
tone: Number(summary.value.backlog_count || 0) ? 'is-warning' : 'is-good'
},
{
key: 'signature',
label: '签名失败',
value: summary.value.signature_failure_count == null ? '待耐久遥测' : number(summary.value.signature_failure_count),
detail: payload.value?.signature_failure_metric?.reason || '已采集签名失败次数',
tone: summary.value.signature_failure_count == null ? 'is-neutral' : 'is-good'
},
{
key: 'reconciliation',
label: '对账异常',
value: number(summary.value.reconciliation_anomaly_count),
detail: 'exception / reopened 对账记录',
tone: Number(summary.value.reconciliation_anomaly_count || 0) ? 'is-warning' : 'is-good'
}
])
async function load() {
if (!canView.value) return
const currentSequence = ++requestSequence
loading.value = true
error.value = ''
try {
const result = await fetchFinancialConnectorObservability({ windowHours: props.windowHours })
if (currentSequence === requestSequence) payload.value = result
} catch (loadError) {
if (currentSequence === requestSequence) {
error.value = String(loadError?.message || '财务连接器指标加载失败。')
}
} finally {
if (currentSequence === requestSequence) loading.value = false
}
}
function number(value) {
return new Intl.NumberFormat('zh-CN').format(Number(value || 0))
}
function percent(value) {
return `${(Number(value || 0) * 100).toFixed(1)}%`
}
function formatTime(value) {
const timestamp = Date.parse(String(value || ''))
if (!Number.isFinite(timestamp)) return '暂无成功'
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
}).format(new Date(timestamp))
}
function statusLabel(value) {
return ({ active: '运行中', disabled: '已停用', rotating: '轮换中' })[value] || '未知'
}
function evidenceTone(item) {
if (item?.evidence_classification === 'external_cash') return 'is-external'
if (item?.evidence_classification === 'staging_connector') return 'is-staging'
return 'is-simulated'
}
onMounted(load)
watch(() => [canView.value, props.windowHours], load)
</script>
<style scoped>
.connector-health { margin: 18px 0; padding: 20px; display: grid; gap: 18px; }
.connector-health__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.connector-health__head h3 { margin: 3px 0 5px; font-size: 20px; }
.connector-health__head p, .connector-health footer { margin: 0; color: var(--text-secondary); font-size: 13px; }
.connector-health__eyebrow { color: var(--theme-primary); font-size: 11px; font-weight: 800; letter-spacing: .12em; }
.connector-health__head button { border: 1px solid var(--border-color); border-radius: 10px; padding: 8px 12px; background: var(--surface); color: var(--text-primary); cursor: pointer; }
.connector-health__head button:disabled { cursor: wait; opacity: .6; }
.connector-health__metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
.connector-health__metrics article { padding: 13px; border: 1px solid var(--border-color); border-radius: 12px; background: color-mix(in srgb, var(--surface) 88%, transparent); }
.connector-health__metrics article.is-warning { border-color: color-mix(in srgb, #f59e0b 45%, var(--border-color)); }
.connector-health__metrics article.is-neutral { border-style: dashed; }
.connector-health__metrics span, .connector-health__metrics small { display: block; color: var(--text-secondary); }
.connector-health__metrics strong { display: block; margin: 6px 0 4px; font-size: 18px; }
.connector-health__metrics small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.connector-health__evidence { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
.connector-health__evidence span, .evidence-chip, .status-chip { border-radius: 999px; padding: 5px 9px; font-weight: 700; }
.is-external { color: #047857; background: #ecfdf5; }
.is-manual { color: #b45309; background: #fffbeb; }
.is-simulated, .evidence-chip.is-simulated { color: #475569; background: #f1f5f9; }
.evidence-chip.is-staging { color: #6d28d9; background: #f5f3ff; }
.status-chip.active { color: #047857; background: #ecfdf5; }
.status-chip.disabled { color: #64748b; background: #f1f5f9; }
.status-chip.rotating { color: #a16207; background: #fefce8; }
.connector-health__table-wrap { overflow-x: auto; }
.connector-health table { width: 100%; border-collapse: collapse; font-size: 13px; }
.connector-health th, .connector-health td { padding: 11px 9px; border-bottom: 1px solid var(--border-color); text-align: left; white-space: nowrap; }
.connector-health td small { display: block; margin-top: 3px; color: var(--text-secondary); }
.connector-health__state { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 84px; color: var(--text-secondary); }
.connector-health__state.is-error { color: #b91c1c; }
@media (max-width: 1180px) { .connector-health__metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 720px) {
.connector-health { padding: 16px; }
.connector-health__head { align-items: stretch; flex-direction: column; }
.connector-health__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>

View File

@@ -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' }

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

View File

@@ -34,7 +34,7 @@ import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation', 'value'])
function resolveDocumentDetailReturnTarget(value) {
const target = String(value || '').trim()
@@ -116,7 +116,8 @@ export function useAppShell() {
if (detailReturnTarget.value === 'conversation') {
return '返回对话'
}
return detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
if (detailReturnTarget.value === 'workbench') return '返回首页'
return detailReturnTarget.value === 'value' ? '返回经营价值' : '返回单据中心'
})
const detailAlerts = computed(() => (
detailMode.value
@@ -585,6 +586,17 @@ export function useAppShell() {
return nextQuery
}
function buildValueDashboardReturnQuery() {
const nextQuery = {}
Object.entries(buildDocumentReturnQuery()).forEach(([key, value]) => {
if (['range', 'start', 'end'].includes(key) || key.startsWith('value_')) {
nextQuery[key] = value
}
})
nextQuery.dashboard = 'value'
return nextQuery
}
function openRequestDetail(request, options = {}) {
const requestId = resolveRequestDetailLookupId(request)
if (!requestId) {
@@ -609,6 +621,10 @@ export function useAppShell() {
return router.push({ name: 'app-workbench' })
}
if (detailReturnTarget.value === 'value') {
return router.push({ name: 'app-overview', query: buildValueDashboardReturnQuery() })
}
return router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
}

View File

@@ -0,0 +1,424 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
executeSavingsOpportunityAction,
executeSavingsRealizationAction,
fetchCfoValueDashboard,
fetchSavingsOpportunities,
fetchSavingsOpportunity
} from '../services/analyticsValue.js'
import {
buildBreakdownGroups,
buildDataQualityRows,
buildFunnelRows,
buildGuardrailRows,
buildOpportunityRows,
buildTrendSeries,
buildValueKpis,
classifyCfoDashboardState,
createEmptyValueFilters,
createRequestId,
isDashboardStale,
readValueFiltersFromQuery,
resolveRangeWindow,
writeValueFiltersToQuery
} from '../views/scripts/cfoValueDashboardModel.js'
import {
hasValueOpportunityQuery,
normalizeValueOpportunityId,
opportunityMatchesValueContext,
readValueOpportunityId,
shouldClearValueOpportunityError,
writeValueOpportunityToQuery
} from '../views/scripts/cfoValueSourceLinks.js'
const PAGE_SIZE = 12
export function useCfoValueDashboard(options = {}) {
const route = useRoute()
const router = useRouter()
const initialFilters = readValueFiltersFromQuery(route.query)
const filterDraft = reactive({ ...initialFilters })
const appliedFilters = ref({ ...initialFilters })
const page = ref(readPage(route.query.value_page))
const dashboard = ref(null)
const dashboardLoading = ref(false)
const dashboardError = ref(null)
const opportunities = ref(emptyOpportunityPage())
const opportunitiesLoading = ref(false)
const opportunitiesError = ref(null)
const selectedOpportunity = ref(null)
const selectedOpportunityId = ref('')
const selectedOpportunityLoading = ref(false)
const selectedOpportunityError = ref(null)
const detailOpen = ref(false)
const mutationLoading = ref(false)
const mutationError = ref(null)
const mutationMessage = ref('')
const trendCurrency = ref('')
const activeBreakdownDimension = ref('department')
let dashboardRequestSeq = 0
let opportunityRequestSeq = 0
let detailRequestSeq = 0
const dateWindow = computed(() => resolveRangeWindow(
options.activeRange,
options.customRange || {}
))
const dashboardState = computed(() => classifyCfoDashboardState({
dashboard: dashboard.value,
error: dashboardError.value,
loading: dashboardLoading.value
}))
const dashboardStale = computed(() => isDashboardStale(dashboard.value || {}))
const kpis = computed(() => buildValueKpis(dashboard.value || {}))
const funnelRows = computed(() => buildFunnelRows(dashboard.value || {}))
const trend = computed(() => buildTrendSeries(dashboard.value || {}, trendCurrency.value))
const breakdownGroups = computed(() => buildBreakdownGroups(dashboard.value || {}))
const activeBreakdown = computed(() => (
breakdownGroups.value.find((group) => group.dimension === activeBreakdownDimension.value)
|| breakdownGroups.value[0]
|| { dimension: '', label: '暂无维度', items: [] }
))
const guardrailRows = computed(() => buildGuardrailRows(dashboard.value || {}))
const dataQualityRows = computed(() => buildDataQualityRows(dashboard.value || {}))
const opportunityRows = computed(() => buildOpportunityRows(opportunities.value))
const hasPartialEvidence = computed(() => (
dashboardState.value === 'partial'
|| dataQualityRows.value.some((item) => item.count > 0)
))
async function loadDashboard() {
const requestSeq = ++dashboardRequestSeq
dashboardLoading.value = true
dashboardError.value = null
try {
const payload = await fetchCfoValueDashboard({
...dateWindow.value,
asOf: new Date().toISOString(),
...withoutStatus(appliedFilters.value)
})
if (requestSeq !== dashboardRequestSeq) return
dashboard.value = payload
if (!trend.value.currencies.includes(trendCurrency.value)) {
trendCurrency.value = trend.value.currencies[0] || ''
}
if (!breakdownGroups.value.some((group) => group.dimension === activeBreakdownDimension.value)) {
activeBreakdownDimension.value = breakdownGroups.value[0]?.dimension || ''
}
} catch (error) {
if (requestSeq === dashboardRequestSeq) dashboardError.value = error
} finally {
if (requestSeq === dashboardRequestSeq) dashboardLoading.value = false
}
}
async function loadOpportunities() {
const requestSeq = ++opportunityRequestSeq
opportunitiesLoading.value = true
opportunitiesError.value = null
try {
const payload = await fetchSavingsOpportunities({
...appliedFilters.value,
page: page.value,
pageSize: PAGE_SIZE,
createdFrom: dateWindow.value.start,
createdTo: dateWindow.value.end,
sort: 'created_desc'
})
if (requestSeq === opportunityRequestSeq) {
opportunities.value = payload
const lastPage = Math.max(1, Number(payload.totalPages || 0))
if (page.value > lastPage) void setPage(lastPage)
}
} catch (error) {
if (requestSeq === opportunityRequestSeq) opportunitiesError.value = error
} finally {
if (requestSeq === opportunityRequestSeq) opportunitiesLoading.value = false
}
}
async function reloadAll() {
await Promise.allSettled([loadDashboard(), loadOpportunities()])
}
async function loadOpportunityDetail(opportunityId) {
const normalizedId = normalizeValueOpportunityId(opportunityId)
if (!normalizedId) {
await clearUnavailableOpportunity()
return
}
const requestSeq = ++detailRequestSeq
selectedOpportunityId.value = normalizedId
detailOpen.value = true
selectedOpportunity.value = null
selectedOpportunityError.value = null
selectedOpportunityLoading.value = true
try {
const payload = await fetchSavingsOpportunity(normalizedId)
if (requestSeq !== detailRequestSeq) return
if (!opportunityMatchesValueContext(payload, appliedFilters.value, dateWindow.value)) {
await clearUnavailableOpportunity()
return
}
selectedOpportunity.value = payload
} catch (error) {
if (requestSeq !== detailRequestSeq) return
if (shouldClearValueOpportunityError(error)) {
await clearUnavailableOpportunity()
return
}
selectedOpportunityError.value = error
} finally {
if (requestSeq === detailRequestSeq) selectedOpportunityLoading.value = false
}
}
async function openOpportunity(opportunityId) {
const normalizedId = normalizeValueOpportunityId(opportunityId)
if (!normalizedId) {
await clearUnavailableOpportunity()
return
}
const nextQuery = writeValueOpportunityToQuery(route.query, normalizedId)
if (sameQuery(nextQuery, route.query)) {
await loadOpportunityDetail(normalizedId)
return
}
await router.push({ query: nextQuery })
}
async function closeOpportunity() {
resetOpportunityDetail()
const nextQuery = writeValueOpportunityToQuery(route.query)
if (!sameQuery(nextQuery, route.query)) await router.push({ query: nextQuery })
}
function resetOpportunityDetail() {
detailRequestSeq += 1
detailOpen.value = false
selectedOpportunity.value = null
selectedOpportunityId.value = ''
selectedOpportunityError.value = null
selectedOpportunityLoading.value = false
}
async function clearUnavailableOpportunity() {
resetOpportunityDetail()
const nextQuery = writeValueOpportunityToQuery(route.query)
if (!sameQuery(nextQuery, route.query)) await router.replace({ query: nextQuery })
}
async function syncOpportunityFromRoute() {
const opportunityId = readValueOpportunityId(route.query)
if (!opportunityId) {
resetOpportunityDetail()
if (hasValueOpportunityQuery(route.query)) await clearUnavailableOpportunity()
return
}
if (
opportunityId === selectedOpportunityId.value
&& selectedOpportunity.value
&& opportunityMatchesValueContext(selectedOpportunity.value, appliedFilters.value, dateWindow.value)
) {
detailOpen.value = true
return
}
if (opportunityId === selectedOpportunityId.value && selectedOpportunityLoading.value) return
await loadOpportunityDetail(opportunityId)
}
async function submitAction(command = {}) {
mutationLoading.value = true
mutationError.value = null
mutationMessage.value = ''
const requestId = command.requestId || createRequestId('cfo-value')
try {
let response
if (command.kind === 'record' || command.action === 'record_realization') {
throw new Error('实际结果必须由付款事件或具备可追溯凭证的连接器写入。')
} else if (command.kind === 'realization') {
response = await executeSavingsRealizationAction(command.realizationId, {
action: command.action,
requestId,
expectedVersion: command.expectedVersion,
comment: command.comment,
...(command.action === 'reverse' ? { reversalAmount: command.reversalAmount } : {}),
evidence: []
})
} else {
response = await executeSavingsOpportunityAction(command.opportunityId, {
action: command.action,
requestId,
expectedVersion: command.expectedVersion,
comment: command.comment
})
}
mutationMessage.value = response.replayed ? '请求已处理,本次返回原执行结果。' : '操作已完成并写入价值证据链。'
selectedOpportunity.value = response.opportunity || selectedOpportunity.value
await reloadAll()
if (detailOpen.value && command.opportunityId) {
await loadOpportunityDetail(command.opportunityId)
}
return response
} catch (error) {
mutationError.value = error
throw error
} finally {
mutationLoading.value = false
}
}
async function applyFilters() {
const nextFilters = Object.fromEntries(
Object.entries(filterDraft).map(([key, value]) => [key, String(value || '').trim()])
)
const nextQuery = writeValueFiltersToQuery(route.query, nextFilters)
delete nextQuery.value_page
if (sameQuery(nextQuery, route.query)) {
appliedFilters.value = nextFilters
page.value = 1
await reloadAll()
return
}
await router.replace({ query: nextQuery })
}
async function resetFilters() {
Object.assign(filterDraft, createEmptyValueFilters())
await applyFilters()
}
async function setPage(nextPage) {
const normalized = Math.max(1, Number(nextPage || 1))
const nextQuery = { ...route.query }
if (normalized > 1) nextQuery.value_page = String(normalized)
else delete nextQuery.value_page
if (sameQuery(nextQuery, route.query)) {
page.value = normalized
await loadOpportunities()
return
}
await router.replace({ query: nextQuery })
}
watch(
() => [
valueFilterRouteSignature(route.query),
readPage(route.query.value_page),
valueOpportunityRouteSignature(route.query)
],
([filterSignature, nextPage, opportunitySignature], previous = []) => {
const [previousFilterSignature, previousPage] = previous
const nextFilters = readValueFiltersFromQuery(route.query)
Object.assign(filterDraft, nextFilters)
appliedFilters.value = nextFilters
page.value = nextPage
if (filterSignature !== previousFilterSignature) {
void reloadAll().then(() => syncOpportunityFromRoute())
} else if (nextPage !== previousPage) {
void loadOpportunities()
}
if (filterSignature === previousFilterSignature && opportunitySignature !== previous[2]) {
void syncOpportunityFromRoute()
}
}
)
watch(
() => [options.activeRange, options.customRange?.start, options.customRange?.end],
() => {
void Promise.allSettled([loadDashboard(), setPage(1)]).then(() => syncOpportunityFromRoute())
}
)
watch(() => trend.value.selectedCurrency, (currency) => {
if (currency && currency !== trendCurrency.value) trendCurrency.value = currency
}, { immediate: true })
onMounted(() => {
void reloadAll().then(() => syncOpportunityFromRoute())
})
return {
activeBreakdown,
activeBreakdownDimension,
appliedFilters,
breakdownGroups,
dashboard,
dashboardError,
dashboardLoading,
dashboardState,
dashboardStale,
dataQualityRows,
dateWindow,
detailOpen,
filterDraft,
funnelRows,
guardrailRows,
hasPartialEvidence,
kpis,
mutationError,
mutationLoading,
mutationMessage,
opportunities,
opportunitiesError,
opportunitiesLoading,
opportunityRows,
page,
selectedOpportunity,
selectedOpportunityId,
selectedOpportunityError,
selectedOpportunityLoading,
trend,
trendCurrency,
applyFilters,
closeOpportunity,
loadDashboard,
loadOpportunities,
openOpportunity,
reloadAll,
resetFilters,
setPage,
submitAction
}
}
function emptyOpportunityPage() {
return { items: [], total: 0, page: 1, pageSize: PAGE_SIZE, totalPages: 0, generatedAt: '' }
}
function withoutStatus(filters = {}) {
const { status: _status, ...dashboardFilters } = filters
return dashboardFilters
}
function readPage(value) {
const raw = Array.isArray(value) ? value[0] : value
const page = Number(raw || 1)
return Number.isInteger(page) && page > 0 ? page : 1
}
function valueFilterRouteSignature(query = {}) {
return JSON.stringify(
Object.fromEntries(
Object.entries(query)
.filter(([key]) => key.startsWith('value_') && !['value_page', 'value_opportunity'].includes(key))
.sort(([left], [right]) => left.localeCompare(right))
)
)
}
function valueOpportunityRouteSignature(query = {}) {
if (!Object.hasOwn(query, 'value_opportunity')) return ''
const value = query.value_opportunity
return JSON.stringify(Array.isArray(value) ? value.map(String) : String(value || ''))
}
function sameQuery(left = {}, right = {}) {
const normalize = (query) => Object.entries(query)
.map(([key, value]) => [key, Array.isArray(value) ? value.map(String) : String(value)])
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right))
}

View File

@@ -0,0 +1,293 @@
import { computed, reactive, ref, toValue, watch } from 'vue'
import {
activateCommercialEntitlement,
activateCommercialPlan,
activateCommercialSubscription,
buildCommercialPricingScenario,
createCommercialPlan,
createCommercialSubscription,
fetchCommercialAccount,
fetchCommercialAnalytics,
fetchCommercialCostEvents,
fetchCommercialEntitlements,
fetchCommercialPlans,
fetchCommercialSubscriptions,
fetchCommercialUsageEvents,
recordCommercialCost,
recordCommercialUsage,
transitionCommercialSubscription,
upsertCommercialEntitlement
} from '../services/commercial.js'
import {
buildAnalyticsWindow,
buildCommercialMutation,
buildCommercialValueLedgers,
buildPricingScenarioPayload,
buildPricingScenarioView,
buildQuotaRows,
classifyCommercialAccountState,
createDefaultCommercialRange
} from '../components/commercial/commercialWorkspaceModel.js'
function resolveBoolean(value) {
return Boolean(toValue(value))
}
function resolveText(value) {
return String(toValue(value) || '').trim()
}
export function useCommercialWorkspace(options = {}) {
const account = ref(null)
const accountLoading = ref(false)
const accountError = ref(null)
const analytics = ref(null)
const analyticsLoading = ref(false)
const analyticsError = ref(null)
const mutationLoading = ref(false)
const mutationError = ref(null)
const mutationMessage = ref('')
const pricingScenario = ref(null)
const pricingLoading = ref(false)
const pricingError = ref(null)
const history = reactive({ plans: [], subscriptions: [], entitlements: [], usageEvents: [], costEvents: [] })
const historyLoading = ref(false)
const historyError = ref(null)
const historyLoaded = ref(false)
const range = reactive(createDefaultCommercialRange())
let accountRequestSeq = 0
let analyticsRequestSeq = 0
let historyRequestSeq = 0
let pricingRequestSeq = 0
const tenantId = computed(() => resolveText(options.tenantId))
const platformAdmin = computed(() => resolveBoolean(options.platformAdmin))
const active = computed(() => options.active === undefined || resolveBoolean(options.active))
const canManage = computed(() => platformAdmin.value && Boolean(tenantId.value))
const accountState = computed(() => classifyCommercialAccountState({
loading: accountLoading.value,
error: accountError.value,
account: account.value
}))
const quotaRows = computed(() => buildQuotaRows(account.value))
const ledgers = computed(() => buildCommercialValueLedgers(analytics.value))
const pricingScenarioView = computed(() => buildPricingScenarioView(pricingScenario.value))
async function loadAccount() {
if (!active.value) return null
const requestSeq = ++accountRequestSeq
accountLoading.value = true
accountError.value = null
try {
const payload = await fetchCommercialAccount({
tenantId: tenantId.value,
platformAdmin: platformAdmin.value
})
if (requestSeq === accountRequestSeq) account.value = payload
return payload
} catch (error) {
if (requestSeq === accountRequestSeq) accountError.value = error
throw error
} finally {
if (requestSeq === accountRequestSeq) accountLoading.value = false
}
}
async function loadAnalytics() {
if (!active.value || !platformAdmin.value || !tenantId.value) {
analyticsRequestSeq += 1
analytics.value = null
analyticsError.value = null
analyticsLoading.value = false
return null
}
const requestSeq = ++analyticsRequestSeq
analyticsLoading.value = true
analyticsError.value = null
try {
const payload = await fetchCommercialAnalytics(
tenantId.value,
buildAnalyticsWindow(range)
)
if (requestSeq === analyticsRequestSeq) analytics.value = payload
return payload
} catch (error) {
if (requestSeq === analyticsRequestSeq) analyticsError.value = error
throw error
} finally {
if (requestSeq === analyticsRequestSeq) analyticsLoading.value = false
}
}
async function reload() {
const operations = [loadAccount()]
if (platformAdmin.value && tenantId.value) operations.push(loadAnalytics())
return Promise.allSettled(operations)
}
async function loadHistory() {
if (!active.value || !platformAdmin.value || !tenantId.value) return null
const requestSeq = ++historyRequestSeq
historyLoading.value = true
historyError.value = null
const listOptions = { limit: 100, offset: 0 }
const eventOptions = { limit: 200, offset: 0 }
const requests = [
['plans', fetchCommercialPlans(tenantId.value, listOptions)],
['subscriptions', fetchCommercialSubscriptions(tenantId.value, listOptions)],
['entitlements', fetchCommercialEntitlements(tenantId.value, { ...listOptions, limit: 200 })],
['usageEvents', fetchCommercialUsageEvents(tenantId.value, eventOptions)],
['costEvents', fetchCommercialCostEvents(tenantId.value, eventOptions)]
]
const results = await Promise.allSettled(requests.map(([, request]) => request))
if (requestSeq !== historyRequestSeq) return null
const failures = []
results.forEach((result, index) => {
const key = requests[index][0]
if (result.status === 'fulfilled') history[key] = Array.isArray(result.value) ? result.value : []
else {
history[key] = []
failures.push(`${key}: ${result.reason?.message || '加载失败'}`)
}
})
historyLoaded.value = true
historyLoading.value = false
if (failures.length) {
historyError.value = new Error(`部分商业历史加载失败:${failures.join('')}`)
}
return history
}
async function calculatePricingScenario(rawForm) {
if (!canManage.value) {
const error = new Error('只有平台管理员可以为显式选择的租户计算定价场景。')
error.status = 403
pricingError.value = error
throw error
}
const requestSeq = ++pricingRequestSeq
pricingLoading.value = true
pricingError.value = null
try {
const payload = buildPricingScenarioPayload(rawForm)
const response = await buildCommercialPricingScenario(tenantId.value, payload)
if (requestSeq === pricingRequestSeq) pricingScenario.value = response
return response
} catch (error) {
if (requestSeq === pricingRequestSeq) pricingError.value = error
throw error
} finally {
if (requestSeq === pricingRequestSeq) pricingLoading.value = false
}
}
async function executeMutation(kind, rawForm) {
if (!canManage.value) {
const error = new Error('只有平台管理员可以修改商业配置和事实台账。')
error.status = 403
throw error
}
const payload = buildCommercialMutation(kind, rawForm, account.value)
mutationLoading.value = true
mutationError.value = null
mutationMessage.value = ''
try {
let response
if (kind === 'createPlan') {
response = await createCommercialPlan(tenantId.value, payload)
} else if (kind === 'activatePlan') {
response = await activateCommercialPlan(tenantId.value, payload.resourceId, payload.expectedVersion, payload.reason)
} else if (kind === 'createSubscription') {
response = await createCommercialSubscription(tenantId.value, payload)
} else if (kind === 'activateSubscription') {
response = await activateCommercialSubscription(tenantId.value, payload.resourceId, payload.expectedVersion, payload.reason)
} else if (kind === 'transitionSubscription') {
response = await transitionCommercialSubscription(tenantId.value, payload.resourceId, {
expectedVersion: payload.expectedVersion,
targetStatus: payload.targetStatus,
reason: payload.reason
})
} else if (kind === 'upsertEntitlement') {
response = await upsertCommercialEntitlement(tenantId.value, payload)
} else if (kind === 'activateEntitlement') {
response = await activateCommercialEntitlement(tenantId.value, payload.resourceId, payload.expectedVersion, payload.reason)
} else if (kind === 'recordUsage') {
response = await recordCommercialUsage(tenantId.value, payload)
} else if (kind === 'recordCost') {
response = await recordCommercialCost(tenantId.value, payload)
} else {
throw new Error('不支持的商业管理操作。')
}
mutationMessage.value = response?.created === false
? '相同幂等事件已存在,本次返回原记录。'
: '操作已完成,并已刷新商业账户与价值分析。'
await reload()
if (historyLoaded.value) await loadHistory()
return response
} catch (error) {
mutationError.value = error
throw error
} finally {
mutationLoading.value = false
}
}
watch(
() => [tenantId.value, platformAdmin.value, active.value],
([nextTenantId, nextPlatformAdmin, nextActive], previous = []) => {
const [previousTenantId, previousPlatformAdmin, previousActive] = previous
if (!nextActive) return
if (
nextTenantId !== previousTenantId
|| nextPlatformAdmin !== previousPlatformAdmin
|| nextActive !== previousActive
) {
pricingRequestSeq += 1
pricingScenario.value = null
pricingLoading.value = false
pricingError.value = null
historyRequestSeq += 1
historyLoaded.value = false
historyLoading.value = false
historyError.value = null
Object.keys(history).forEach((key) => { history[key] = [] })
void reload()
}
},
{ immediate: options.immediate !== false }
)
return {
account,
accountLoading,
accountError,
accountState,
analytics,
analyticsLoading,
analyticsError,
mutationLoading,
mutationError,
mutationMessage,
pricingScenario,
pricingScenarioView,
pricingLoading,
pricingError,
history,
historyLoading,
historyError,
historyLoaded,
range,
tenantId,
platformAdmin,
canManage,
quotaRows,
ledgers,
loadAccount,
loadAnalytics,
reload,
loadHistory,
calculatePricingScenario,
executeMutation
}
}

View File

@@ -3,7 +3,7 @@ import { h, ref } from 'vue'
export function useLoginView() {
const username = ref('')
const password = ref('')
const tenant = ref('远光软件股份有限公司')
const tenant = ref('default')
const remember = ref(true)
const showPassword = ref(false)

View File

@@ -61,6 +61,8 @@ import {
export function useOverviewView(options = {}) {
const activeDashboardKey = computed(() => {
const dashboard = String(options.dashboard || '').trim()
if (dashboard === 'commercial') return 'commercial'
if (dashboard === 'value') return 'value'
if (dashboard === 'system') return 'system'
if (dashboard === 'risk') return 'risk'
if (dashboard === 'digitalEmployee') return 'digitalEmployee'
@@ -222,6 +224,10 @@ export function useOverviewView(options = {}) {
}
const loadActiveDashboard = () => {
if (activeDashboardKey.value === 'commercial' || activeDashboardKey.value === 'value') {
stopRiskDashboardRealtimeRefresh()
return
}
if (activeDashboardKey.value === 'system') {
void loadSystemDashboard()
stopRiskDashboardRealtimeRefresh()

View File

@@ -93,8 +93,9 @@ function readAuthState() {
}
function buildAnonymousUser() {
return {
username: '',
return {
username: '',
tenantId: '',
name: '',
role: '',
department: '',
@@ -646,10 +647,11 @@ async function handleLogin(credentials) {
loginError.value = ''
try {
const response = await loginByAccount({
username: credentials.username,
password: credentials.password
})
const response = await loginByAccount({
username: credentials.username,
password: credentials.password,
tenantId: credentials.tenantId
})
const responseUser = normalizeStoredAuthUser(response?.user || buildAnonymousUser())
const responseRoleCodes = responseUser.roleCodes

View File

@@ -1,4 +1,4 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../useSystemState.js'
import { useToast } from '../useToast.js'
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
@@ -7,9 +7,7 @@ import { calculateTravelReimbursement, fetchExpenseClaimDetail } from '../../ser
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
import {
deleteAiWorkbenchConversation,
loadAiWorkbenchConversationHistory,
markAiWorkbenchConversationDocumentDeleted,
saveAiWorkbenchConversation
markAiWorkbenchConversationDocumentDeleted
} from '../../utils/aiWorkbenchConversationStore.js'
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
import {
@@ -41,23 +39,12 @@ import {
} from './workbenchAiApplicationGateModel.js'
import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js'
import {
buildRuleFallbackWorkbenchAiIntentPlan,
isLowConfidenceTravelApplicationPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan,
shouldRequestWorkbenchAiIntentPlan
} from './workbenchAiIntentPlannerModel.js'
import {
buildInitialModelPlanningThinkingEvents,
buildModelPlanningProgressSchedule,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
import { useWorkbenchAiConversationRuntime } from './useWorkbenchAiConversationRuntime.js'
import { useWorkbenchAiIntentExecution } from './useWorkbenchAiIntentExecution.js'
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
const INLINE_ANSWER_STREAM_DELAY_MS = 24
const INLINE_AUTO_SCROLL_THRESHOLD = 96
const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
export function usePersonalWorkbenchAiMode(props, emit) {
const { currentUser } = useSystemState()
@@ -126,7 +113,47 @@ export function usePersonalWorkbenchAiMode(props, emit) {
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: () => focusAiModeInput() })
const conversationRuntime = useWorkbenchAiConversationRuntime({
activeConversationTitle,
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft,
assistantInputRef,
attachmentOcrExpandedMessageIds,
clearAiModeFiles: () => filesFlow.clearAiModeFiles(),
clearWorkbenchDateSelection,
conversationId,
conversationMessages,
conversationScrollRef,
conversationStarted,
currentUser,
deleteDialogOpen,
emit,
inlineConversationAutoScrollPinned,
searchConversationId: AI_SEARCH_CONVERSATION_ID,
serializeRuntimeMessage,
stewardState,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds
})
const {
activateInlineConversation,
appendInlineMessageContent,
focusAiModeInput,
handleInlineConversationScroll,
persistCurrentConversation,
refreshConversationHistory,
replaceInlineMessage,
resetInlineConversationState,
scrollInlineConversationToBottom,
scrollInlineConversationToTop,
setAssistantInputRef,
streamInlineAssistantContent,
streamOrSetInlineAssistantContent,
updateInlineMessageContent
} = conversationRuntime
const aiModeActionItems = AI_MODE_ACTION_ITEMS
const displayUserName = computed(() => {
@@ -225,6 +252,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
thinkingExpandedMessageIds
})
let intentExecution = null
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation,
applicationPreviewEditor,
@@ -259,7 +288,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
scrollInlineConversationToBottom,
sending,
toast,
onApplicationActionCompleted: startModelPlannedNextTask
onApplicationActionCompleted: (remainingTasks) => intentExecution?.startModelPlannedNextTask(remainingTasks)
})
const expenseFlow = useWorkbenchAiExpenseFlow({
@@ -354,6 +383,29 @@ export function usePersonalWorkbenchAiMode(props, emit) {
toast
})
intentExecution = useWorkbenchAiIntentExecution({
actionRouter,
activeConversationTitle,
activateInlineConversation,
applicationFlow,
assistantDraft,
clearAiModeFiles: filesFlow.clearAiModeFiles,
closeWorkbenchDatePicker,
conversationId,
conversationMessages,
createInlineMessage,
expenseFlow,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveInlineThinkingEvents,
scrollInlineConversationToBottom,
searchConversationId: AI_SEARCH_CONVERSATION_ID,
sending,
stewardFlow
})
const applicationPreviewEstimatePending = computed(() => (
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
))
@@ -378,187 +430,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
}
function focusAiModeInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function setAssistantInputRef(element) {
assistantInputRef.value = element
}
function isInlineConversationNearBottom() {
const el = conversationScrollRef.value
if (!el) {
return true
}
return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD
}
function handleInlineConversationScroll() {
inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom()
}
function forceInlineConversationToBottom() {
const el = conversationScrollRef.value
if (el) {
el.scrollTop = el.scrollHeight
inlineConversationAutoScrollPinned.value = true
}
}
function scrollInlineConversationToBottom(options = {}) {
const shouldScroll = options.force !== false
nextTick(() => {
if (!shouldScroll) {
return
}
forceInlineConversationToBottom()
window.requestAnimationFrame(() => {
forceInlineConversationToBottom()
})
window.setTimeout(() => {
if (inlineConversationAutoScrollPinned.value) {
forceInlineConversationToBottom()
}
}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS)
})
}
function scrollInlineConversationToTop() {
nextTick(() => {
const el = conversationScrollRef.value
if (el) {
inlineConversationAutoScrollPinned.value = false
el.scrollTo({ top: 0, behavior: 'smooth' })
}
})
}
function updateInlineMessageContent(message, content) {
if (!message) {
return
}
message.content = String(content || '')
message.paragraphs = String(message.content || '')
.split(/\n{2,}|\n/)
.map((item) => item.trim())
.filter(Boolean)
}
function appendInlineMessageContent(message, delta) {
const nextDelta = String(delta || '')
if (!nextDelta) {
return
}
updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`)
}
function waitInlineAnswerStreamFrame() {
return new Promise((resolve) => {
window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS)
})
}
async function streamInlineAssistantContent(messageId, content) {
const targetContent = String(content || '').trim()
let streamedContent = ''
for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message || !message.pending) {
return
}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE)
updateInlineMessageContent(message, streamedContent)
scrollInlineConversationToBottom({ force: shouldAutoScroll })
await waitInlineAnswerStreamFrame()
}
}
async function streamOrSetInlineAssistantContent(messageId, content) {
const targetContent = String(content || '').trim()
if (/<!--\s*ai-trusted-html:start\s*-->/.test(targetContent)) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (message?.pending) {
updateInlineMessageContent(message, targetContent)
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
return
}
await streamInlineAssistantContent(messageId, targetContent)
}
function refreshConversationHistory() {
const history = loadAiWorkbenchConversationHistory(currentUser.value || {})
emit('conversation-history-change', history)
return history
}
function isPersistableInlineConversation() {
return Boolean(
conversationId.value &&
conversationId.value !== AI_SEARCH_CONVERSATION_ID &&
conversationMessages.value.length
)
}
function persistCurrentConversation() {
if (!isPersistableInlineConversation()) {
refreshConversationHistory()
return []
}
const history = saveAiWorkbenchConversation(currentUser.value || {}, {
id: conversationId.value,
conversationId: conversationId.value,
title: activeConversationTitle.value,
source: 'workbench',
sessionType: 'steward',
stewardState: stewardState.value,
messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message))
})
emit('conversation-history-change', history)
return history
}
function resetInlineConversationState() {
conversationStarted.value = false
conversationMessages.value = []
conversationId.value = ''
stewardState.value = null
activeConversationTitle.value = ''
assistantDraft.value = ''
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
attachmentOcrExpandedMessageIds.value = new Set()
deleteDialogOpen.value = false
applicationSubmitConfirmOpen.value = false
applicationSubmitConfirmContext.value = null
clearWorkbenchDateSelection()
filesFlow.clearAiModeFiles()
}
function replaceInlineMessage(id, nextMessage) {
const index = conversationMessages.value.findIndex((item) => item.id === id)
if (index === -1) {
conversationMessages.value.push(nextMessage)
return
}
conversationMessages.value.splice(index, 1, nextMessage)
}
function activateInlineConversation(options = {}) {
conversationStarted.value = true
if (!conversationId.value) {
conversationId.value = options.id || `inline-${Date.now()}`
}
activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话'
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
}
function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) }
function isUnavailableDocumentDetailError(error) {
@@ -648,238 +519,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return false
}
function isModelPlannedReimbursementTask(modelPlan = {}) {
const tasks = Array.isArray(modelPlan?.tasks) ? modelPlan.tasks : []
return tasks.some((task) => {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
})
}
function updateModelPlanningThinkingEvent(messageId, event) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
const currentPlan = message.stewardPlan || {}
message.stewardPlan = {
...currentPlan,
streamStatus: 'streaming',
thinkingEvents: mergeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(message), [event])
}
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
function startModelPlanningProgressUpdates(messageId) {
const timerIds = buildModelPlanningProgressSchedule().map(({ delayMs, event }) => (
globalThis.setTimeout(() => {
updateModelPlanningThinkingEvent(messageId, event)
}, delayMs)
))
return () => {
timerIds.forEach((timerId) => globalThis.clearTimeout(timerId))
}
}
function startModelPlanningConversation(cleanPrompt, entry = {}) {
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
conversationId.value = ''
conversationMessages.value = []
activeConversationTitle.value = ''
}
activateInlineConversation({
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
})
inlineConversationAutoScrollPinned.value = true
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
filesFlow.clearAiModeFiles()
const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildInitialModelPlanningThinkingEvents()
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
persistCurrentConversation()
return pendingMessage
}
function buildModelPlannedNextTaskAction(remainingTasks = []) {
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
const nextTask = tasks[0]
if (!nextTask || typeof nextTask !== 'object') {
return null
}
const taskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const assignedAgent = String(nextTask.assigned_agent || nextTask.assignedAgent || '').trim()
const isApplication = taskType === 'expense_application' || assignedAgent === 'application_assistant'
const isReimbursement = taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
if (!isApplication && !isReimbursement) {
return null
}
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
return {
label: `继续处理${taskLabel}`,
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: tasks.slice(1)
}
}
}
function startModelPlannedNextTask(remainingTasks = []) {
const nextTaskAction = buildModelPlannedNextTaskAction(remainingTasks)
if (!nextTaskAction) {
return
}
actionRouter.handleInlineSuggestedAction(nextTaskAction)
}
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
void applicationFlow.startAiApplicationPreview(
travelApplicationRequest.expenseType,
travelApplicationRequest.expenseTypeLabel,
travelApplicationRequest.sourceText,
{
userMessage: travelApplicationRequest.sourceText,
pushUserMessage: !plannerPendingMessage,
pendingMessageId: plannerPendingMessage?.id,
ontologyFields: travelApplicationRequest.ontologyFields,
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
onPreviewReadyForNextTask: startModelPlannedNextTask,
onApplicationActionCompleted: startModelPlannedNextTask
}
)
}
function startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, plan, plannerPendingMessage) {
const confirmText = buildLowConfidenceTravelApplicationConfirmationText(travelApplicationRequest, plan)
const confirmAction = {
label: '确认发起出差申请',
description: '根据上面识别到的信息生成出差申请预览。',
icon: 'mdi mdi-check-circle-outline',
action_type: 'ai_application_confirm_intent',
payload: {
ontologyFields: travelApplicationRequest.ontologyFields,
sourceText: travelApplicationRequest.sourceText,
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
}
}
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, {
id: plannerPendingMessage.id,
suggestedActions: [confirmAction],
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' }))
}
}))
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
function buildLowConfidenceTravelApplicationConfirmationText(request, plan) {
const fields = request.ontologyFields || {}
const summaryParts = []
if (fields.time_range) {
summaryParts.push(`时间:${fields.time_range}`)
}
if (fields.location) {
summaryParts.push(`地点:${fields.location}`)
}
if (fields.reason) {
summaryParts.push(`事由:${fields.reason}`)
}
if (fields.transport_mode) {
summaryParts.push(`交通:${fields.transport_mode}`)
}
const summary = summaryParts.length ? `\n\n${summaryParts.join('')}` : ''
const confidenceNote = Number.isFinite(Number(plan?.confidence))
? `(模型识别置信度较低,约 ${Math.round(Number(plan.confidence) * 100)}%`
: '(模型识别置信度较低)'
return [
'### 需要确认:您是要发起出差申请吗?',
'',
`小财管家把这句话理解成了“发起差旅申请”${confidenceNote},为避免误操作,先请您确认。`,
summary,
'',
'点击下方「确认发起出差申请」即可继续;如果理解有误,请补充说明您的实际需求。'
].filter(Boolean).join('\n')
}
async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) {
let intentPlan = null
let modelPlan = null
const plannerPendingMessage = startModelPlanningConversation(cleanPrompt, entry)
const stopPlanningProgressUpdates = startModelPlanningProgressUpdates(plannerPendingMessage.id)
sending.value = true
try {
modelPlan = await stewardFlow.resolveInlineExecutionPlan(cleanPrompt, entry, files, {
pendingMessageId: plannerPendingMessage.id
})
intentPlan = normalizeWorkbenchAiIntentPlan(modelPlan, { prompt: cleanPrompt })
} catch (error) {
console.warn('AI mode intent planner failed, using local fallback:', error)
const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt)
const ruleRequest = resolveExecutableTravelApplicationPlan(rulePlan)
if (ruleRequest) {
sending.value = false
startModelPlannedApplicationPreview(ruleRequest, plannerPendingMessage)
return
}
} finally {
stopPlanningProgressUpdates()
sending.value = false
}
const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan)
if (travelApplicationRequest) {
if (isLowConfidenceTravelApplicationPlan(intentPlan)) {
startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, intentPlan, plannerPendingMessage)
return
}
startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage)
return
}
if (isModelPlannedReimbursementTask(modelPlan) || isReimbursementCreationIntent(cleanPrompt)) {
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', '已识别为报销任务,正在进入报销流程。', {
id: plannerPendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' }))
}
}))
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || cleanPrompt)
return
}
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id })
}
async function handleAiAnswerMarkdownClick(event) {
const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
@@ -923,7 +562,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
void intentExecution.executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
return
}

View File

@@ -0,0 +1,234 @@
import { nextTick } from 'vue'
import {
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} from '../../utils/aiWorkbenchConversationStore.js'
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
const INLINE_ANSWER_STREAM_DELAY_MS = 24
const INLINE_AUTO_SCROLL_THRESHOLD = 96
const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
export function useWorkbenchAiConversationRuntime(options) {
const {
activeConversationTitle,
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft,
assistantInputRef,
attachmentOcrExpandedMessageIds,
clearAiModeFiles,
clearWorkbenchDateSelection,
conversationId,
conversationMessages,
conversationScrollRef,
conversationStarted,
currentUser,
deleteDialogOpen,
emit,
inlineConversationAutoScrollPinned,
searchConversationId,
serializeRuntimeMessage,
stewardState,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds
} = options
function focusAiModeInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function setAssistantInputRef(element) {
assistantInputRef.value = element
}
function isInlineConversationNearBottom() {
const el = conversationScrollRef.value
if (!el) {
return true
}
return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD
}
function handleInlineConversationScroll() {
inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom()
}
function forceInlineConversationToBottom() {
const el = conversationScrollRef.value
if (el) {
el.scrollTop = el.scrollHeight
inlineConversationAutoScrollPinned.value = true
}
}
function scrollInlineConversationToBottom(options = {}) {
const shouldScroll = options.force !== false
nextTick(() => {
if (!shouldScroll) {
return
}
forceInlineConversationToBottom()
window.requestAnimationFrame(() => {
forceInlineConversationToBottom()
})
window.setTimeout(() => {
if (inlineConversationAutoScrollPinned.value) {
forceInlineConversationToBottom()
}
}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS)
})
}
function scrollInlineConversationToTop() {
nextTick(() => {
const el = conversationScrollRef.value
if (el) {
inlineConversationAutoScrollPinned.value = false
el.scrollTo({ top: 0, behavior: 'smooth' })
}
})
}
function updateInlineMessageContent(message, content) {
if (!message) {
return
}
message.content = String(content || '')
message.paragraphs = String(message.content || '')
.split(/\n{2,}|\n/)
.map((item) => item.trim())
.filter(Boolean)
}
function appendInlineMessageContent(message, delta) {
const nextDelta = String(delta || '')
if (!nextDelta) {
return
}
updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`)
}
function waitInlineAnswerStreamFrame() {
return new Promise((resolve) => {
window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS)
})
}
async function streamInlineAssistantContent(messageId, content) {
const targetContent = String(content || '').trim()
let streamedContent = ''
for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message || !message.pending) {
return
}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE)
updateInlineMessageContent(message, streamedContent)
scrollInlineConversationToBottom({ force: shouldAutoScroll })
await waitInlineAnswerStreamFrame()
}
}
async function streamOrSetInlineAssistantContent(messageId, content) {
const targetContent = String(content || '').trim()
if (/<!--\s*ai-trusted-html:start\s*-->/.test(targetContent)) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (message?.pending) {
updateInlineMessageContent(message, targetContent)
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
return
}
await streamInlineAssistantContent(messageId, targetContent)
}
function refreshConversationHistory() {
const history = loadAiWorkbenchConversationHistory(currentUser.value || {})
emit('conversation-history-change', history)
return history
}
function isPersistableInlineConversation() {
return Boolean(
conversationId.value &&
conversationId.value !== searchConversationId &&
conversationMessages.value.length
)
}
function persistCurrentConversation() {
if (!isPersistableInlineConversation()) {
refreshConversationHistory()
return []
}
const history = saveAiWorkbenchConversation(currentUser.value || {}, {
id: conversationId.value,
conversationId: conversationId.value,
title: activeConversationTitle.value,
source: 'workbench',
sessionType: 'steward',
stewardState: stewardState.value,
messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message))
})
emit('conversation-history-change', history)
return history
}
function resetInlineConversationState() {
conversationStarted.value = false
conversationMessages.value = []
conversationId.value = ''
stewardState.value = null
activeConversationTitle.value = ''
assistantDraft.value = ''
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
attachmentOcrExpandedMessageIds.value = new Set()
deleteDialogOpen.value = false
applicationSubmitConfirmOpen.value = false
applicationSubmitConfirmContext.value = null
clearWorkbenchDateSelection()
clearAiModeFiles()
}
function replaceInlineMessage(id, nextMessage) {
const index = conversationMessages.value.findIndex((item) => item.id === id)
if (index === -1) {
conversationMessages.value.push(nextMessage)
return
}
conversationMessages.value.splice(index, 1, nextMessage)
}
function activateInlineConversation(options = {}) {
conversationStarted.value = true
if (!conversationId.value) {
conversationId.value = options.id || `inline-${Date.now()}`
}
activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话'
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
}
return {
activateInlineConversation,
appendInlineMessageContent,
focusAiModeInput,
handleInlineConversationScroll,
persistCurrentConversation,
refreshConversationHistory,
replaceInlineMessage,
resetInlineConversationState,
scrollInlineConversationToBottom,
scrollInlineConversationToTop,
setAssistantInputRef,
streamInlineAssistantContent,
streamOrSetInlineAssistantContent,
updateInlineMessageContent
}
}

View File

@@ -0,0 +1,277 @@
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
import {
buildRuleFallbackWorkbenchAiIntentPlan,
isLowConfidenceTravelApplicationPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan
} from './workbenchAiIntentPlannerModel.js'
import {
buildInitialModelPlanningThinkingEvents,
buildModelPlanningProgressSchedule,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
export function useWorkbenchAiIntentExecution(options) {
const {
actionRouter,
activeConversationTitle,
activateInlineConversation,
applicationFlow,
assistantDraft,
clearAiModeFiles,
closeWorkbenchDatePicker,
conversationId,
conversationMessages,
createInlineMessage,
expenseFlow,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveInlineThinkingEvents,
scrollInlineConversationToBottom,
searchConversationId,
sending,
stewardFlow
} = options
function isModelPlannedReimbursementTask(modelPlan = {}) {
const tasks = Array.isArray(modelPlan?.tasks) ? modelPlan.tasks : []
return tasks.some((task) => {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
})
}
function updateModelPlanningThinkingEvent(messageId, event) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
const currentPlan = message.stewardPlan || {}
message.stewardPlan = {
...currentPlan,
streamStatus: 'streaming',
thinkingEvents: mergeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(message), [event])
}
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
function startModelPlanningProgressUpdates(messageId) {
const timerIds = buildModelPlanningProgressSchedule().map(({ delayMs, event }) => (
globalThis.setTimeout(() => {
updateModelPlanningThinkingEvent(messageId, event)
}, delayMs)
))
return () => {
timerIds.forEach((timerId) => globalThis.clearTimeout(timerId))
}
}
function startModelPlanningConversation(cleanPrompt, entry = {}) {
if (conversationId.value === searchConversationId) {
conversationId.value = ''
conversationMessages.value = []
activeConversationTitle.value = ''
}
activateInlineConversation({
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
})
inlineConversationAutoScrollPinned.value = true
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
clearAiModeFiles()
const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildInitialModelPlanningThinkingEvents()
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
persistCurrentConversation()
return pendingMessage
}
function buildModelPlannedNextTaskAction(remainingTasks = []) {
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
const nextTask = tasks[0]
if (!nextTask || typeof nextTask !== 'object') {
return null
}
const taskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const assignedAgent = String(nextTask.assigned_agent || nextTask.assignedAgent || '').trim()
const isApplication = taskType === 'expense_application' || assignedAgent === 'application_assistant'
const isReimbursement = taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
if (!isApplication && !isReimbursement) {
return null
}
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
return {
label: `继续处理${taskLabel}`,
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: tasks.slice(1)
}
}
}
function startModelPlannedNextTask(remainingTasks = []) {
const nextTaskAction = buildModelPlannedNextTaskAction(remainingTasks)
if (nextTaskAction) {
actionRouter.handleInlineSuggestedAction(nextTaskAction)
}
}
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
void applicationFlow.startAiApplicationPreview(
travelApplicationRequest.expenseType,
travelApplicationRequest.expenseTypeLabel,
travelApplicationRequest.sourceText,
{
userMessage: travelApplicationRequest.sourceText,
pushUserMessage: !plannerPendingMessage,
pendingMessageId: plannerPendingMessage?.id,
ontologyFields: travelApplicationRequest.ontologyFields,
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
onPreviewReadyForNextTask: startModelPlannedNextTask,
onApplicationActionCompleted: startModelPlannedNextTask
}
)
}
function buildLowConfidenceTravelApplicationConfirmationText(request, plan) {
const fields = request.ontologyFields || {}
const summaryParts = []
if (fields.time_range) summaryParts.push(`时间:${fields.time_range}`)
if (fields.location) summaryParts.push(`地点:${fields.location}`)
if (fields.reason) summaryParts.push(`事由:${fields.reason}`)
if (fields.transport_mode) summaryParts.push(`交通:${fields.transport_mode}`)
const summary = summaryParts.length ? `\n\n${summaryParts.join('')}` : ''
const confidenceNote = Number.isFinite(Number(plan?.confidence))
? `(模型识别置信度较低,约 ${Math.round(Number(plan.confidence) * 100)}%`
: '(模型识别置信度较低)'
return [
'### 需要确认:您是要发起出差申请吗?',
'',
`小财管家把这句话理解成了“发起差旅申请”${confidenceNote},为避免误操作,先请您确认。`,
summary,
'',
'点击下方「确认发起出差申请」即可继续;如果理解有误,请补充说明您的实际需求。'
].filter(Boolean).join('\n')
}
function startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, plan, plannerPendingMessage) {
const confirmAction = {
label: '确认发起出差申请',
description: '根据上面识别到的信息生成出差申请预览。',
icon: 'mdi mdi-check-circle-outline',
action_type: 'ai_application_confirm_intent',
payload: {
ontologyFields: travelApplicationRequest.ontologyFields,
sourceText: travelApplicationRequest.sourceText,
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
}
}
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage(
'assistant',
buildLowConfidenceTravelApplicationConfirmationText(travelApplicationRequest, plan),
{
id: plannerPendingMessage.id,
suggestedActions: [confirmAction],
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage)
.map((item) => ({ ...item, status: 'completed' }))
}
}
))
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) {
let intentPlan = null
let modelPlan = null
const plannerPendingMessage = startModelPlanningConversation(cleanPrompt, entry)
const stopPlanningProgressUpdates = startModelPlanningProgressUpdates(plannerPendingMessage.id)
sending.value = true
try {
modelPlan = await stewardFlow.resolveInlineExecutionPlan(cleanPrompt, entry, files, {
pendingMessageId: plannerPendingMessage.id
})
intentPlan = normalizeWorkbenchAiIntentPlan(modelPlan, { prompt: cleanPrompt })
} catch (error) {
console.warn('AI mode intent planner failed, using local fallback:', error)
const ruleRequest = resolveExecutableTravelApplicationPlan(
buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt)
)
if (ruleRequest) {
sending.value = false
startModelPlannedApplicationPreview(ruleRequest, plannerPendingMessage)
return
}
} finally {
stopPlanningProgressUpdates()
sending.value = false
}
const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan)
if (travelApplicationRequest) {
if (isLowConfidenceTravelApplicationPlan(intentPlan)) {
startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, intentPlan, plannerPendingMessage)
} else {
startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage)
}
return
}
if (isModelPlannedReimbursementTask(modelPlan) || isReimbursementCreationIntent(cleanPrompt)) {
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage(
'assistant',
'已识别为报销任务,正在进入报销流程。',
{
id: plannerPendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage)
.map((item) => ({ ...item, status: 'completed' }))
}
}
))
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || cleanPrompt)
return
}
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, {
pendingMessageId: plannerPendingMessage.id
})
}
return {
executeModelPlannedWorkbenchIntent,
startModelPlannedNextTask
}
}

View File

@@ -265,6 +265,99 @@ export function publishRiskRuleAsset(assetId, options = {}) {
})
}
export function fetchAgentAssetReleaseState(assetId) {
return apiRequest(`/agent-assets/${assetId}/release`)
}
const RELEASE_REVIEW_METRIC_KEYS = [
'observed_count',
'runtime_failure_count',
'runtime_failure_rate',
'negative_sample_count',
'negative_labeled_count',
'negative_pending_label_count',
'false_negative_count',
'estimated_false_negative_count',
'false_negative_upper_bound',
'random_negative_population_count',
'random_negative_sample_count',
'random_negative_labeled_count',
'recall',
'recall_lower_bound',
'recall_confidence_level',
'recall_method',
'negative_ground_truth_status'
]
const RELEASE_REVIEW_LABELS = new Set(['risk_present', 'risk_absent'])
export function normalizeAgentAssetReleaseReviewQueue(payload = {}) {
const metrics = payload?.metrics && typeof payload.metrics === 'object' ? payload.metrics : {}
return {
asset_id: text(payload.asset_id),
release_id: text(payload.release_id),
stage: text(payload.stage),
version: text(payload.version),
pending_total: count(payload.pending_total),
telemetry_status: text(payload.telemetry_status),
reasons: Array.isArray(payload.reasons) ? payload.reasons.map(text).filter(Boolean) : [],
metrics: Object.fromEntries(
RELEASE_REVIEW_METRIC_KEYS
.filter((key) => Object.hasOwn(metrics, key))
.map((key) => [key, metrics[key]])
),
alerts: (Array.isArray(payload.alerts) ? payload.alerts : []).map((alert) => ({
code: text(alert?.code),
severity: text(alert?.severity),
message: text(alert?.message),
recommended_action: text(alert?.recommended_action)
})),
items: (Array.isArray(payload.items) ? payload.items : []).map((item) => ({
sample_id: text(item?.sample_id),
observation_id: text(item?.observation_id),
source_document_id: text(item?.source_document_id),
rule_code: text(item?.rule_code),
business_stage: text(item?.business_stage),
prediction_blinded: item?.prediction_blinded === true,
reviewer_count: count(item?.reviewer_count),
required_reviewers: Math.max(1, count(item?.required_reviewers, 1)),
conflicted: item?.conflicted === true,
created_at: text(item?.created_at)
}))
}
}
export function normalizeAgentAssetReleaseReviewLabel(label) {
const normalized = text(label)
if (!RELEASE_REVIEW_LABELS.has(normalized)) {
throw new TypeError('盲审结论必须是 risk_present 或 risk_absent。')
}
return normalized
}
export async function fetchAgentAssetReleaseReviewQueue(assetId, limit = 50) {
const payload = await apiRequest(
`/agent-assets/${assetId}/release/review-queue${buildQuery({ limit })}`
)
return normalizeAgentAssetReleaseReviewQueue(payload)
}
export function labelAgentAssetReleaseObservation(
assetId,
observationId,
label,
options = {}
) {
const reviewLabel = normalizeAgentAssetReleaseReviewLabel(label)
return apiRequest(
`/agent-assets/${assetId}/release/review-queue/${observationId}/labels`,
{
method: 'POST',
body: JSON.stringify({ label: reviewLabel }),
headers: buildWriteHeaders(options)
}
)
}
export function setRiskRuleAssetEnabled(assetId, enabled, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-enabled`, {
method: 'POST',
@@ -273,6 +366,15 @@ export function setRiskRuleAssetEnabled(assetId, enabled, options = {}) {
})
}
function text(value) {
return String(value || '').trim()
}
function count(value, fallback = 0) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
export function setRiskRuleAssetLevel(assetId, riskLevel, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-level`, {
method: 'POST',

View File

@@ -0,0 +1,139 @@
import { apiRequest } from './api.js'
const FILTER_QUERY_KEYS = [
'departmentId',
'projectCode',
'expenseType',
'supplierId',
'city',
'ownerId',
'sourceType',
'valueKind'
]
const OPPORTUNITY_QUERY_KEYS = [
...FILTER_QUERY_KEYS,
'status',
'claimId',
'createdFrom',
'createdTo',
'sort',
'page',
'pageSize'
]
function camelizeKey(value) {
return String(value || '').replace(/_([a-z0-9])/gu, (_, letter) => letter.toUpperCase())
}
export function normalizeAnalyticsValuePayload(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeAnalyticsValuePayload(item))
}
if (!value || typeof value !== 'object' || value instanceof Date) {
return value
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
camelizeKey(key),
normalizeAnalyticsValuePayload(item)
])
)
}
function snakeCaseKey(value) {
return String(value || '').replace(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`)
}
function appendQueryValue(search, key, value) {
if (value === undefined || value === null || value === '') {
return
}
search.set(snakeCaseKey(key), String(value))
}
export function buildCfoValueSearch(options = {}) {
const search = new URLSearchParams()
appendQueryValue(search, 'start', options.start)
appendQueryValue(search, 'end', options.end)
appendQueryValue(search, 'asOf', options.asOf)
FILTER_QUERY_KEYS.forEach((key) => appendQueryValue(search, key, options[key]))
return search
}
export function buildSavingsOpportunitySearch(options = {}) {
const search = new URLSearchParams()
OPPORTUNITY_QUERY_KEYS.forEach((key) => appendQueryValue(search, key, options[key]))
return search
}
function buildMutationBody(payload = {}) {
if (Array.isArray(payload)) {
return payload.map((item) => buildMutationBody(item))
}
if (!payload || typeof payload !== 'object' || payload instanceof Date) {
return payload
}
return Object.fromEntries(
Object.entries(payload).map(([key, value]) => [snakeCaseKey(key), buildMutationBody(value)])
)
}
export async function fetchCfoValueDashboard(options = {}) {
const search = buildCfoValueSearch(options)
const suffix = search.size ? `?${search.toString()}` : ''
const payload = await apiRequest(`/analytics/cfo-value${suffix}`, {
timeoutMs: Number(options.timeoutMs || 10_000),
timeoutMessage: '经营价值看板加载超时,请稍后重试。'
})
return normalizeAnalyticsValuePayload(payload)
}
export async function fetchSavingsOpportunities(options = {}) {
const search = buildSavingsOpportunitySearch(options)
const suffix = search.size ? `?${search.toString()}` : ''
const payload = await apiRequest(`/savings/opportunities${suffix}`, {
timeoutMs: Number(options.timeoutMs || 10_000),
timeoutMessage: '节省机会台账加载超时,请稍后重试。'
})
return normalizeAnalyticsValuePayload(payload)
}
export async function fetchSavingsOpportunity(opportunityId, options = {}) {
const payload = await apiRequest(`/savings/opportunities/${encodeURIComponent(opportunityId)}`, {
timeoutMs: Number(options.timeoutMs || 10_000),
timeoutMessage: '节省机会证据链加载超时,请稍后重试。'
})
return normalizeAnalyticsValuePayload(payload)
}
export async function executeSavingsOpportunityAction(opportunityId, payload) {
const response = await apiRequest(`/savings/opportunities/${encodeURIComponent(opportunityId)}/actions`, {
method: 'POST',
body: JSON.stringify(buildMutationBody(payload)),
timeoutMs: 15_000,
timeoutMessage: '节省机会动作执行超时,可使用同一操作窗口安全重试。'
})
return normalizeAnalyticsValuePayload(response)
}
export async function recordSavingsRealization(opportunityId, payload) {
const response = await apiRequest(`/savings/opportunities/${encodeURIComponent(opportunityId)}/realizations`, {
method: 'POST',
body: JSON.stringify(buildMutationBody(payload)),
timeoutMs: 15_000,
timeoutMessage: '实际结果写入超时,可使用同一操作窗口安全重试。'
})
return normalizeAnalyticsValuePayload(response)
}
export async function executeSavingsRealizationAction(realizationId, payload) {
const response = await apiRequest(`/savings/realizations/${encodeURIComponent(realizationId)}/actions`, {
method: 'POST',
body: JSON.stringify(buildMutationBody(payload)),
timeoutMs: 15_000,
timeoutMessage: '财务确认动作执行超时,可使用同一操作窗口安全重试。'
})
return normalizeAnalyticsValuePayload(response)
}

View File

@@ -0,0 +1,250 @@
import { apiRequest } from './api.js'
const DEFAULT_TIMEOUT_MS = 12_000
const MUTATION_TIMEOUT_MS = 15_000
let requestSequence = 0
function camelizeKey(value) {
return String(value || '').replace(/_([a-z0-9])/gu, (_, letter) => letter.toUpperCase())
}
function snakeCaseKey(value) {
return String(value || '').replace(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`)
}
export function normalizeCommercialPayload(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeCommercialPayload(item))
}
if (!value || typeof value !== 'object' || value instanceof Date) {
return value
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
camelizeKey(key),
normalizeCommercialPayload(item)
])
)
}
export function serializeCommercialPayload(value) {
if (Array.isArray(value)) {
return value.map((item) => serializeCommercialPayload(item))
}
if (!value || typeof value !== 'object' || value instanceof Date) {
return value
}
return Object.fromEntries(
Object.entries(value)
.filter(([, item]) => item !== undefined)
.map(([key, item]) => [
snakeCaseKey(key),
serializeCommercialPayload(item)
])
)
}
function requireTenantId(tenantId) {
const normalized = String(tenantId || '').trim()
if (!normalized) {
throw new Error('请选择需要管理的租户。')
}
return normalized
}
function tenantAdminPath(tenantId, suffix = '') {
return `/commercial/admin/tenants/${encodeURIComponent(requireTenantId(tenantId))}${suffix}`
}
function appendQuery(search, key, value) {
if (value === undefined || value === null || value === '') {
return
}
search.set(snakeCaseKey(key), String(value))
}
export function buildCommercialAnalyticsSearch(options = {}) {
const search = new URLSearchParams()
appendQuery(search, 'start', options.start)
appendQuery(search, 'end', options.end)
appendQuery(search, 'asOf', options.asOf)
return search
}
function buildCommercialListSearch(options = {}, keys = []) {
const search = new URLSearchParams()
keys.forEach((key) => appendQuery(search, key, options[key]))
appendQuery(search, 'limit', options.limit)
appendQuery(search, 'offset', options.offset)
return search
}
function withSearch(path, search) {
return search.size ? `${path}?${search.toString()}` : path
}
async function readCommercial(path, options = {}) {
const payload = await apiRequest(path, {
timeoutMs: Number(options.timeoutMs || DEFAULT_TIMEOUT_MS),
timeoutMessage: options.timeoutMessage || '商业账户加载超时,请稍后重试。'
})
return normalizeCommercialPayload(payload)
}
async function mutateCommercial(path, method, payload, timeoutMessage) {
requestSequence += 1
const requestId = `commercial-web-${Date.now().toString(36)}-${requestSequence.toString(36)}`
const response = await apiRequest(path, {
method,
headers: { 'X-Request-Id': requestId },
body: JSON.stringify(serializeCommercialPayload(payload)),
timeoutMs: MUTATION_TIMEOUT_MS,
timeoutMessage
})
return normalizeCommercialPayload(response)
}
export async function fetchCommercialAccount(options = {}) {
const tenantId = String(options.tenantId || '').trim()
const path = options.platformAdmin && tenantId
? tenantAdminPath(tenantId, '/account')
: '/commercial/account'
const search = new URLSearchParams()
appendQuery(search, 'asOf', options.asOf)
const suffix = search.size ? `?${search.toString()}` : ''
return readCommercial(`${path}${suffix}`, options)
}
export async function fetchCommercialAnalytics(tenantId, options = {}) {
const search = buildCommercialAnalyticsSearch(options)
const suffix = search.size ? `?${search.toString()}` : ''
return readCommercial(`${tenantAdminPath(tenantId, '/analytics')}${suffix}`, {
...options,
timeoutMessage: '商业价值分析加载超时,请稍后重试。'
})
}
export function buildCommercialPricingScenario(tenantId, payload) {
return mutateCommercial(
tenantAdminPath(tenantId, '/pricing-scenarios'),
'POST',
payload,
'定价场景计算超时,本次计算不会自动创建或修改套餐,请确认后重试。'
)
}
export function fetchCommercialPlans(tenantId, options = {}) {
return readCommercial(withSearch(
tenantAdminPath(tenantId, '/plans'),
buildCommercialListSearch(options, ['planStatus'])
), options)
}
export function fetchCommercialSubscriptions(tenantId, options = {}) {
return readCommercial(withSearch(
tenantAdminPath(tenantId, '/subscriptions'),
buildCommercialListSearch(options, ['subscriptionStatus'])
), options)
}
export function fetchCommercialEntitlements(tenantId, options = {}) {
return readCommercial(withSearch(
tenantAdminPath(tenantId, '/entitlements'),
buildCommercialListSearch(options, ['subscriptionId', 'entitlementStatus'])
), options)
}
export function fetchCommercialUsageEvents(tenantId, options = {}) {
return readCommercial(withSearch(
tenantAdminPath(tenantId, '/usage-events'),
buildCommercialListSearch(options, ['subscriptionId', 'start', 'end'])
), options)
}
export function fetchCommercialCostEvents(tenantId, options = {}) {
return readCommercial(withSearch(
tenantAdminPath(tenantId, '/cost-events'),
buildCommercialListSearch(options, ['subscriptionId', 'start', 'end'])
), options)
}
export function createCommercialPlan(tenantId, payload) {
return mutateCommercial(
tenantAdminPath(tenantId, '/plans'),
'POST',
payload,
'套餐创建超时,请刷新账户确认结果后再重试。'
)
}
export function activateCommercialPlan(tenantId, planId, expectedVersion, reason) {
return mutateCommercial(
tenantAdminPath(tenantId, `/plans/${encodeURIComponent(planId)}/activate`),
'POST',
{ expectedVersion, reason },
'套餐激活超时,请刷新版本状态后再重试。'
)
}
export function createCommercialSubscription(tenantId, payload) {
return mutateCommercial(
tenantAdminPath(tenantId, '/subscriptions'),
'POST',
payload,
'订阅创建超时,请刷新账户确认结果后再重试。'
)
}
export function activateCommercialSubscription(tenantId, subscriptionId, expectedVersion, reason) {
return mutateCommercial(
tenantAdminPath(tenantId, `/subscriptions/${encodeURIComponent(subscriptionId)}/activate`),
'POST',
{ expectedVersion, reason },
'订阅激活超时,请刷新版本状态后再重试。'
)
}
export function transitionCommercialSubscription(tenantId, subscriptionId, payload) {
return mutateCommercial(
tenantAdminPath(tenantId, `/subscriptions/${encodeURIComponent(subscriptionId)}/transition`),
'POST',
payload,
'订阅状态变更超时,请刷新版本状态后再重试。'
)
}
export function upsertCommercialEntitlement(tenantId, payload) {
return mutateCommercial(
tenantAdminPath(tenantId, '/entitlements'),
'PUT',
payload,
'权益保存超时,请刷新账户确认结果后再重试。'
)
}
export function activateCommercialEntitlement(tenantId, entitlementId, expectedVersion, reason) {
return mutateCommercial(
tenantAdminPath(tenantId, `/entitlements/${encodeURIComponent(entitlementId)}/activate`),
'POST',
{ expectedVersion, reason },
'权益激活超时,请刷新版本状态后再重试。'
)
}
export function recordCommercialUsage(tenantId, payload) {
return mutateCommercial(
tenantAdminPath(tenantId, '/usage-events'),
'POST',
payload,
'用量事件写入超时,可使用相同幂等键安全重试。'
)
}
export function recordCommercialCost(tenantId, payload) {
return mutateCommercial(
tenantAdminPath(tenantId, '/cost-events'),
'POST',
payload,
'成本事件写入超时,可使用相同幂等键安全重试。'
)
}

View File

@@ -0,0 +1,27 @@
import { apiRequest } from './api.js'
function clampWindowHours(value) {
const parsed = Number(value || 24)
if (!Number.isFinite(parsed)) return 24
return Math.max(1, Math.min(720, Math.round(parsed)))
}
export function fetchFinancialConnectorObservability(options = {}) {
const search = new URLSearchParams()
search.set('window_hours', String(clampWindowHours(options.windowHours)))
return apiRequest(`/financial-connectors/observability?${search.toString()}`, {
timeoutMs: Number(options.timeoutMs || 5000),
timeoutMessage: '财务连接器运行指标加载超时,请稍后重试。'
})
}
export function fetchFinancialPaymentEvidence(claimId, options = {}) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return Promise.reject(new Error('报销单 ID 不能为空。'))
}
return apiRequest(`/financial-connectors/payment-evidence/${encodeURIComponent(normalizedClaimId)}`, {
timeoutMs: Number(options.timeoutMs || 5000),
timeoutMessage: '付款证据等级加载超时,请稍后重试。'
})
}

View File

@@ -314,7 +314,7 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
lines.push(
'',
'**后续行动建议**',
'- 请检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 请检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 若日期无误,请先处理或关联已有申请单,避免重复申请。',
'',
'我会先暂停本次申请表生成,不会开放保存草稿或提交入口。'
@@ -372,7 +372,7 @@ export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck =
lines.push(
'',
'**后续行动建议**',
'- 请核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 请核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;',
'- 若日期无误,请先查看或处理已有申请单,避免重复申请。',
'',
'我会先暂停本次提交,不会生成新的审批流。'

View File

@@ -86,6 +86,7 @@ export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
return {
username,
tenantId: pickText(payload, ['tenantId', 'tenant_id']),
name,
role: String(payload.role || defaults.defaultRole || ''),
department,

View File

@@ -1,6 +1,13 @@
import { buildMockApplicationTransportEstimate } from './expenseApplicationEstimate.js'
import { getTodayDateValue } from './workbenchComposerDate.js'
export {
resolveCurrentUserDepartment,
resolveCurrentUserGrade,
resolveCurrentUserManagerName,
resolveCurrentUserPosition
} from './expenseApplicationUserProfile.js'
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'applicationType', label: '申请类型' },
{ key: 'applicant', label: '姓名', editable: false, required: false },
@@ -459,48 +466,6 @@ function uniqueApplicationCandidates(values) {
.filter((item, index, list) => list.indexOf(item) === index)
}
export function resolveCurrentUserGrade(currentUser = {}) {
return String(
currentUser.grade
|| currentUser.employeeGrade
|| currentUser.employee_grade
|| currentUser.profileGrade
|| ''
).trim()
}
export function resolveCurrentUserDepartment(currentUser = {}) {
return String(
currentUser.department
|| currentUser.departmentName
|| currentUser.department_name
|| ''
).trim()
}
export function resolveCurrentUserPosition(currentUser = {}) {
return String(
currentUser.position
|| currentUser.employeePosition
|| currentUser.employee_position
|| currentUser.jobTitle
|| currentUser.job_title
|| ''
).trim()
}
export function resolveCurrentUserManagerName(currentUser = {}) {
return String(
currentUser.managerName
|| currentUser.manager_name
|| currentUser.directManagerName
|| currentUser.direct_manager_name
|| currentUser.leaderName
|| currentUser.leader_name
|| ''
).trim()
}
export function parseApplicationDaysValue(value) {
const match = String(value || '').match(/\d+/)
const days = match ? Number(match[0]) : parseChineseNumber(value)

View File

@@ -0,0 +1,41 @@
export function resolveCurrentUserGrade(currentUser = {}) {
return String(
currentUser.grade
|| currentUser.employeeGrade
|| currentUser.employee_grade
|| currentUser.profileGrade
|| ''
).trim()
}
export function resolveCurrentUserDepartment(currentUser = {}) {
return String(
currentUser.department
|| currentUser.departmentName
|| currentUser.department_name
|| ''
).trim()
}
export function resolveCurrentUserPosition(currentUser = {}) {
return String(
currentUser.position
|| currentUser.employeePosition
|| currentUser.employee_position
|| currentUser.jobTitle
|| currentUser.job_title
|| ''
).trim()
}
export function resolveCurrentUserManagerName(currentUser = {}) {
return String(
currentUser.managerName
|| currentUser.manager_name
|| currentUser.directManagerName
|| currentUser.direct_manager_name
|| currentUser.leaderName
|| currentUser.leader_name
|| ''
).trim()
}

View File

@@ -130,6 +130,7 @@
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
:dashboard="overviewDashboard"
:current-user="currentUser"
:active-range="activeRange"
:custom-range="customRange"
@approve="handleApprove"
@@ -241,6 +242,7 @@
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
@@ -271,7 +273,11 @@ const budgetDetailOpen = ref(false)
const sidebarCollapsed = ref(false)
const sidebarCollapsedBeforeAiMode = ref(false)
const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance')
const route = useRoute()
const router = useRouter()
const OVERVIEW_DASHBOARDS = new Set(['finance', 'value', 'commercial', 'risk', 'digitalEmployee', 'system'])
const OVERVIEW_RANGES = new Set(['今日', '近10日', '本周', '本月', 'custom'])
const overviewDashboard = ref(resolveOverviewDashboard(route.query.dashboard))
const { companyProfile, currentUser, logout } = useSystemState()
function resolveDefaultWorkbenchMode(user) {
@@ -385,7 +391,7 @@ const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
const isAiShellMode = computed(() => workbenchMode.value === 'ai')
const isWorkbenchAiMode = computed(() => activeView.value === 'workbench' && workbenchMode.value === 'ai')
const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation', 'value'])
const DETAIL_TOPBAR_FALLBACKS = {
audit: {
title: '规则中心详情',
@@ -428,6 +434,30 @@ const resolvedDetailKpis = computed(() => (
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
))
function resolveOverviewDashboard(value) {
const normalized = Array.isArray(value) ? String(value[0] || '') : String(value || '')
return OVERVIEW_DASHBOARDS.has(normalized) ? normalized : 'finance'
}
function resolveOverviewRange(value) {
const normalized = Array.isArray(value) ? String(value[0] || '') : String(value || '')
return OVERVIEW_RANGES.has(normalized) ? normalized : '近10日'
}
function resolveOverviewDate(value) {
const normalized = Array.isArray(value) ? String(value[0] || '') : String(value || '')
return /^\d{4}-\d{2}-\d{2}$/u.test(normalized) ? normalized : ''
}
function overviewQuerySignature(query = {}) {
return JSON.stringify({
dashboard: query.dashboard || '',
range: query.range || '',
start: query.start || '',
end: query.end || ''
})
}
function resolveDocumentDetailReturnTarget(value) {
const target = String(value || '').trim()
return DOCUMENT_DETAIL_RETURN_TARGETS.has(target) ? target : ''
@@ -601,4 +631,48 @@ watch(
},
{ immediate: true }
)
watch(
() => [activeView.value, overviewQuerySignature(route.query)],
([view]) => {
if (view !== 'overview') return
overviewDashboard.value = resolveOverviewDashboard(route.query.dashboard)
activeRange.value = resolveOverviewRange(route.query.range)
if (activeRange.value === 'custom') {
const start = resolveOverviewDate(route.query.start)
const end = resolveOverviewDate(route.query.end)
if (start && end && start <= end) customRange.value = { start, end }
}
},
{ immediate: true }
)
watch(
() => [
activeView.value,
overviewDashboard.value,
activeRange.value,
customRange.value?.start,
customRange.value?.end
],
([view]) => {
if (view !== 'overview') return
const nextQuery = {
...route.query,
dashboard: resolveOverviewDashboard(overviewDashboard.value),
range: resolveOverviewRange(activeRange.value)
}
if (activeRange.value === 'custom' && customRange.value?.start && customRange.value?.end) {
nextQuery.start = customRange.value.start
nextQuery.end = customRange.value.end
} else {
delete nextQuery.start
delete nextQuery.end
}
if (overviewQuerySignature(nextQuery) !== overviewQuerySignature(route.query)) {
void router.replace({ query: nextQuery })
}
},
{ immediate: true }
)
</script>

View File

@@ -48,6 +48,14 @@
v-else-if="selectedSkill.usesJsonRiskRule"
:selected-skill="selectedSkill"
:risk-rule-test-passed="riskRuleTestPassed"
:release-state="releaseState"
:release-queue="releaseQueue"
:release-loading="releaseLoading"
:release-error="releaseError"
:release-action="releaseAction"
:can-review-release="canManageSelected"
@refresh-release="loadReleaseMonitor"
@label-release="submitReleaseLabel"
/>
<div

View File

@@ -1,18 +1,27 @@
<template>
<section class="budget-center-page">
<article v-if="!detailMode" class="budget-list panel">
<nav class="status-tabs budget-scope-tabs" aria-label="预算中心视角">
<button
v-for="tab in budgetScopeTabs"
:key="tab.value"
type="button"
:class="{ active: activeBudgetScope === tab.value }"
@click="activeBudgetScope = tab.value"
>
<span>{{ tab.label }}</span>
<small>{{ tab.count }}</small>
</button>
</nav>
<div class="budget-list-head">
<aside v-if="budgetConfigurationFocus.active" class="budget-configuration-focus" role="status">
<i class="mdi mdi-information-outline" aria-hidden="true"></i>
<div>
<strong>预算配置视图 · {{ budgetConfigurationFocusSummary }}</strong>
<span>{{ budgetConfigurationNotice }}</span>
</div>
</aside>
<nav class="status-tabs budget-scope-tabs" aria-label="预算中心视角">
<button
v-for="tab in budgetScopeTabs"
:key="tab.value"
type="button"
:class="{ active: activeBudgetScope === tab.value }"
@click="activeBudgetScope = tab.value"
>
<span>{{ tab.label }}</span>
<small>{{ tab.count }}</small>
</button>
</nav>
</div>
<div class="document-toolbar budget-toolbar">
<div class="filter-set">
@@ -229,6 +238,13 @@
back-label="返回预算中心"
@back="backToList"
>
<aside v-if="budgetConfigurationFocus.active" class="budget-configuration-focus" role="status">
<i class="mdi mdi-information-outline" aria-hidden="true"></i>
<div>
<strong>预算配置视图 · {{ budgetConfigurationFocusSummary }}</strong>
<span>{{ budgetConfigurationNotice }}</span>
</div>
</aside>
<section class="budget-period-grid" aria-label="预算周期金额">
<article v-for="item in selectedBudget.periodRows" :key="item.label" class="budget-period-card">
<span>{{ item.label }}</span>
@@ -292,7 +308,11 @@
</tr>
</thead>
<tbody>
<tr v-for="item in selectedBudget.categoryRows" :key="item.code">
<tr
v-for="item in selectedBudget.categoryRows"
:key="item.code"
:class="{ 'is-source-focus': isFocusedBudgetCategory(item) }"
>
<td data-label="费用类型"><strong>{{ item.name }}</strong></td>
<td data-label="预算金额">{{ item.amountLabel }}</td>
<td data-label="已发生">{{ item.usedLabel }}</td>

View File

@@ -77,7 +77,7 @@
<form
class="login-form"
:class="{ 'is-submitting': submitting }"
@submit.prevent="emit('login', { username, password })"
@submit.prevent="emit('login', { username, password, tenantId: tenant })"
>
<label class="field">
<span class="sr-only">账号</span>
@@ -123,7 +123,7 @@
aria-label="请选择企业或租户"
:disabled="submitting"
>
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
<option value="default">远光软件股份有限公司</option>
</select>
<span class="field-select-chevron" aria-hidden="true">
<i class="mdi mdi-chevron-down"></i>

View File

@@ -9,6 +9,16 @@
/>
</div>
<template v-else>
<CommercialWorkspace
v-if="activeDashboard === 'commercial'"
:current-user="currentUser"
/>
<CfoValueDashboard
v-else-if="activeDashboard === 'value'"
:active-range="activeRange"
:custom-range="customRange"
/>
<template v-else>
<div class="kpi-grid">
<article
v-for="metric in activeKpiMetrics"
@@ -31,6 +41,11 @@
</article>
</div>
<FinancialConnectorHealthPanel
v-if="activeDashboard === 'finance'"
:current-user="currentUser"
/>
<template v-if="activeDashboard === 'finance'">
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
@@ -323,6 +338,7 @@
</aside>
</div>
</template>
</template>
</template>
</section>
</template>
@@ -339,7 +355,10 @@ import SystemAgentRatioBar from '../components/charts/SystemAgentRatioBar.vue'
import SystemLoginWaveChart from '../components/charts/SystemLoginWaveChart.vue'
import SystemTokenDailyWaveChart from '../components/charts/SystemTokenDailyWaveChart.vue'
import SystemUserTokenPie from '../components/charts/SystemUserTokenPie.vue'
import CommercialWorkspace from '../components/commercial/CommercialWorkspace.vue'
import CfoValueDashboard from '../components/dashboard/CfoValueDashboard.vue'
import DigitalEmployeeDashboard from '../components/dashboard/DigitalEmployeeDashboard.vue'
import FinancialConnectorHealthPanel from '../components/dashboard/FinancialConnectorHealthPanel.vue'
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
@@ -348,6 +367,7 @@ import { useOverviewView } from '../composables/useOverviewView.js'
const props = defineProps({
filteredRequests: { type: Array, required: true },
dashboard: { type: String, default: 'finance' },
currentUser: { type: Object, default: () => ({}) },
activeRange: { type: String, default: '近10日' },
customRange: {
type: Object,
@@ -401,18 +421,24 @@ const {
} = useOverviewView(props)
const activeDashboard = computed(() => {
if (props.dashboard === 'commercial') return 'commercial'
if (props.dashboard === 'value') return 'value'
if (props.dashboard === 'system') return 'system'
if (props.dashboard === 'risk') return 'risk'
if (props.dashboard === 'digitalEmployee') return 'digitalEmployee'
return 'finance'
})
const activeKpiMetrics = computed(() => {
if (activeDashboard.value === 'commercial') return []
if (activeDashboard.value === 'value') return []
if (activeDashboard.value === 'system') return systemKpiMetrics.value
if (activeDashboard.value === 'digitalEmployee') return digitalEmployeeKpiMetrics.value
if (activeDashboard.value === 'risk') return riskKpiMetrics.value
return kpiMetrics.value
})
const activeDashboardLoading = computed(() => {
if (activeDashboard.value === 'commercial') return false
if (activeDashboard.value === 'value') return false
if (activeDashboard.value === 'system') {
return systemDashboardLoading.value
}
@@ -425,6 +451,8 @@ const activeDashboardLoading = computed(() => {
return financeDashboardLoading.value
})
const activeDashboardLoadingText = computed(() => {
if (activeDashboard.value === 'commercial') return '正在加载商业化管理与价值证明数据'
if (activeDashboard.value === 'value') return '正在加载经营价值看板数据'
if (activeDashboard.value === 'system') return '正在加载系统看板数据'
if (activeDashboard.value === 'digitalEmployee') return '正在加载数字员工看板数据'
if (activeDashboard.value === 'risk') return '正在加载风险看板数据'

View File

@@ -384,6 +384,10 @@ import {
import { inferPreviewKindFromBlob } from '../utils/documentPreviewAssets.js'
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
import {
formatReceiptDateTime as formatDateTime,
formatReceiptRecognitionScore as formatScore
} from './scripts/receiptFolderFormatting.js'
import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js'
const NEW_CLAIM_VALUE = '__new_claim__'
@@ -783,19 +787,6 @@ async function openAssociationConversation() {
}
}
function formatScore(value) {
const score = Number(value || 0)
if (!Number.isFinite(score) || score <= 0) return '待确认'
return `${Math.round(score * 100)}%`
}
function formatDateTime(value) {
if (!String(value ?? '').trim()) return '待确认'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '待确认'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
</script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>

View File

@@ -40,91 +40,20 @@
</button>
</div>
</div>
<div v-if="isApplicationDocument" class="application-detail-facts">
<div
v-for="item in applicationDetailFactItems"
:key="item.key"
class="application-detail-fact"
:class="{
highlight: item.highlight,
emphasis: item.emphasis,
editable: canEditApplicationDetailItem(item),
editing: isApplicationDetailEditing(item)
}"
>
<span>{{ item.label }}</span>
<strong>
<template v-if="isApplicationDetailEditing(item)">
<ElDatePicker
v-if="resolveApplicationDetailEditorControl(item) === 'date'"
v-model="applicationDetailEditor.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="applicationDetailEditor.saving"
@click.stop
/>
<EnterpriseSelect
v-else-if="resolveApplicationDetailEditorControl(item) === 'select'"
v-model="applicationDetailEditor.draftValue"
class="application-detail-editor-control application-detail-editor-select"
:options="APPLICATION_TRANSPORT_MODE_OPTIONS"
clearable
:teleported="false"
:disabled="applicationDetailEditor.saving"
@click.stop
/>
<ElInput
v-else
v-model="applicationDetailEditor.draftValue"
class="application-detail-editor-control"
clearable
:disabled="applicationDetailEditor.saving"
@click.stop
@keydown.enter.stop.prevent="saveApplicationDetailEdit(item)"
@keydown.esc.stop.prevent="cancelApplicationDetailEditor"
/>
<button
class="application-detail-edit-confirm"
type="button"
title="保存"
aria-label="保存"
:disabled="applicationDetailEditor.saving"
@click.stop="saveApplicationDetailEdit(item)"
>
<i :class="applicationDetailEditor.saving ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-check'"></i>
</button>
<button
class="application-detail-edit-cancel"
type="button"
title="取消"
aria-label="取消"
:disabled="applicationDetailEditor.saving"
@click.stop="cancelApplicationDetailEditor"
>
<i class="mdi mdi-close"></i>
</button>
</template>
<template v-else>
<span class="application-detail-fact-value">{{ item.value }}</span>
<button
v-if="canEditApplicationDetailItem(item)"
class="application-detail-edit-btn"
type="button"
title="编辑"
aria-label="编辑"
:disabled="actionBusy"
@click.stop="openApplicationDetailEditor(item)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</strong>
</div>
</div>
<TravelRequestApplicationFacts
v-if="isApplicationDocument"
:fact-items="applicationDetailFactItems"
:editor="applicationDetailEditor"
:transport-mode-options="APPLICATION_TRANSPORT_MODE_OPTIONS"
:action-busy="actionBusy"
:can-edit-item="canEditApplicationDetailItem"
:is-editing-item="isApplicationDetailEditing"
:resolve-editor-control="resolveApplicationDetailEditorControl"
@update-draft="applicationDetailEditor.draftValue = $event"
@save="saveApplicationDetailEdit"
@cancel="cancelApplicationDetailEditor"
@open="openApplicationDetailEditor"
/>
<TravelRequestBudgetAnalysis
v-if="showBudgetAnalysis"
:claim-id="request.claimId"

View File

@@ -18,6 +18,7 @@ import { useAuditRuleVersionActions } from './useAuditRuleVersionActions.js'
import { useAuditRiskRuleActions } from './useAuditRiskRuleActions.js'
import { useAuditRiskRuleCreateFlow } from './useAuditRiskRuleCreateFlow.js'
import { useAuditRiskRuleJsonEditor } from './useAuditRiskRuleJsonEditor.js'
import { useAuditReleaseMonitor } from './useAuditReleaseMonitor.js'
import { useAuditSpreadsheetEditor } from './useAuditSpreadsheetEditor.js'
import { useAuditVersionTimeline } from './useAuditVersionTimeline.js'
@@ -313,6 +314,19 @@ export default {
resolveActor,
toast
})
const {
releaseState,
releaseQueue,
releaseLoading,
releaseError,
releaseAction,
loadReleaseMonitor,
submitReleaseLabel
} = useAuditReleaseMonitor({
selectedSkill,
canManageSelected,
toast
})
const showReviewNote = computed(
() => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel)
)
@@ -674,6 +688,11 @@ export default {
riskRuleEditOpen,
riskRuleEditMode,
riskRuleEditForm,
releaseState,
releaseQueue,
releaseLoading,
releaseError,
releaseAction,
riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
showReviewNote,
@@ -730,6 +749,8 @@ export default {
closePublishRiskRuleDialog,
publishSelectedRiskRule,
toggleSelectedRiskRuleEnabled,
loadReleaseMonitor,
submitReleaseLabel,
activateSelectedRule,
restoreSelectedVersion,
openVersionTimeline: openVersionTimelineInState,

View File

@@ -1,5 +1,6 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { useRoute } from 'vue-router'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
@@ -17,8 +18,13 @@ import {
} from '../../utils/accessControl.js'
import {
BUDGET_QUARTER_OPTIONS,
BUDGET_YEAR_OPTIONS
BUDGET_YEAR_OPTIONS,
resolveBudgetExpenseTypeLabel
} from '../../utils/budgetOntology.js'
import {
BUDGET_CONFIGURATION_NOTICE,
readBudgetConfigurationFocus
} from './cfoValueSourceLinks.js'
import {
BUDGET_PAGE_SIZE_OPTIONS,
BUDGET_SCOPE_ALL,
@@ -112,6 +118,7 @@ export default {
ElButton
},
setup(props, { emit }) {
const route = useRoute()
const departments = ref(FALLBACK_DEPARTMENTS)
const activeBudgetScope = ref(BUDGET_SCOPE_ALL)
const budgetKeyword = ref('')
@@ -126,6 +133,13 @@ export default {
quarter: 'Q1',
status: '全部'
})
const budgetConfigurationFocus = computed(() => readBudgetConfigurationFocus(route.query))
const budgetConfigurationFocusSummary = computed(() => {
const focus = budgetConfigurationFocus.value
const department = focus.departmentName || focus.departmentId || '当前可见部门'
const expenseType = resolveBudgetExpenseTypeLabel(focus.expenseType, focus.expenseType)
return [department, expenseType].filter(Boolean).join(' · ')
})
const canEditBudget = computed(() =>
canEditBudgetCenter(props.currentUser) || isBudgetMonitorUser(props.currentUser)
@@ -170,6 +184,7 @@ export default {
const filteredBudgetRows = computed(() =>
activeScopeRows.value
.filter((row) => filters.value.status === '全部' || row.statusLabel === filters.value.status)
.filter((row) => matchesBudgetConfigurationExpense(row))
.filter((row) => matchesBudgetKeyword(row, budgetKeyword.value))
)
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
@@ -227,12 +242,16 @@ export default {
const showEmpty = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length === 0)
const emptyState = computed(() => ({
eyebrow: activeScopeLabel.value,
title: `暂无${activeScopeLabel.value}`,
desc: '当前筛选条件下没有匹配的预算记录。',
title: budgetConfigurationFocus.value.active ? '未找到匹配的预算配置' : `暂无${activeScopeLabel.value}`,
desc: budgetConfigurationFocus.value.active
? `当前配置列表没有“${budgetConfigurationFocusSummary.value}”对应的预算科目;这不代表预算为零。`
: '当前筛选条件下没有匹配的预算记录。',
icon: 'mdi mdi-database-search-outline',
tone: 'blue',
artLabel: '预算列表为空',
tips: ['可以调整年度、季度、状态或关键词后重试。']
tips: budgetConfigurationFocus.value.active
? ['返回经营价值看板调整维度,或等待正式预算科目数据接入。']
: ['可以调整年度、季度、状态或关键词后重试。']
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} / ${totalBudgetPages.value}`)
@@ -313,6 +332,50 @@ export default {
selectedBudgetId.value = ''
}
async function applyBudgetConfigurationFocus() {
const focus = budgetConfigurationFocus.value
if (!focus.active) return
const scopeChanged = activeBudgetScope.value !== BUDGET_SCOPE_ALL
activeBudgetScope.value = BUDGET_SCOPE_ALL
filters.value.status = '全部'
budgetPage.value = 1
if (scopeChanged) await nextTick()
const departmentTokens = [focus.departmentId, focus.departmentName]
.map((value) => String(value || '').trim().toLocaleLowerCase('zh-CN'))
.filter(Boolean)
const department = departments.value.find((item) => [
item.id, item.code, item.name, item.costCenter
].some((value) => departmentTokens.includes(String(value || '').trim().toLocaleLowerCase('zh-CN'))))
const rows = budgetRowsByScope.value[BUDGET_SCOPE_ALL] || []
const row = department
? rows.find((item) => (
item.departmentCode === department.code
|| item.departmentName === department.name
|| (department.costCenter && item.costCenter === department.costCenter)
))
: null
selectedBudgetId.value = row && matchesBudgetConfigurationExpense(row) ? row.id : ''
budgetKeyword.value = row ? '' : (focus.departmentName || focus.departmentId || '')
}
function matchesBudgetConfigurationExpense(row = {}) {
const focus = String(budgetConfigurationFocus.value.expenseType || '').trim().toLocaleLowerCase('zh-CN')
if (!budgetConfigurationFocus.value.active || !focus) return true
return (Array.isArray(row.categoryRows) ? row.categoryRows : []).some((item) => (
[item.code, item.name].some((value) => String(value || '').trim().toLocaleLowerCase('zh-CN') === focus)
))
}
function isFocusedBudgetCategory(item = {}) {
const focus = String(budgetConfigurationFocus.value.expenseType || '').trim().toLocaleLowerCase('zh-CN')
if (!focus) return false
return [item.code, item.name].some((value) => (
String(value || '').trim().toLocaleLowerCase('zh-CN') === focus
))
}
function handleRowAction(row) {
if (activeBudgetScope.value === BUDGET_SCOPE_REVIEW && canAuditBudgetDrafts.value) {
openBudgetReviewAssistant(row)
@@ -387,9 +450,19 @@ export default {
}
onMounted(() => {
void loadDepartments()
void loadDepartments().then(() => applyBudgetConfigurationFocus())
})
watch(
() => [
route.query.budget_view,
route.query.budget_department_id,
route.query.budget_department_name,
route.query.budget_expense_type
],
() => void applyBudgetConfigurationFocus()
)
watch(
() => activeBudgetScope.value,
() => {
@@ -439,6 +512,9 @@ export default {
activeBudgetFilterKey,
activeBudgetScope,
budgetError,
budgetConfigurationFocus,
budgetConfigurationFocusSummary,
budgetConfigurationNotice: BUDGET_CONFIGURATION_NOTICE,
budgetKeyword,
budgetLoading,
budgetPage: currentBudgetPage,
@@ -457,6 +533,7 @@ export default {
filters,
goToBudgetPage,
handleRowAction,
isFocusedBudgetCategory,
openBudgetAssistant,
openBudgetDetail,
openBudgetReviewAssistant,

View File

@@ -4,6 +4,7 @@ import { ElInput } from 'element-plus/es/components/input/index.mjs'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
import TravelRequestApplicationFacts from '../../components/travel/TravelRequestApplicationFacts.vue'
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import TravelRequestDetailHero from '../../components/travel/TravelRequestDetailHero.vue'
@@ -22,6 +23,7 @@ export default {
EnterpriseSelect,
StageRiskAdviceCard,
TravelRequestApprovalDialog,
TravelRequestApplicationFacts,
TravelRequestBudgetAnalysis,
TravelRequestDeleteDialog,
TravelRequestDetailHero,

View File

@@ -0,0 +1,357 @@
const STATUS_META = {
identified: { label: '待评估', tone: 'info' },
accepted: { label: '已接受', tone: 'primary' },
in_progress: { label: '执行中', tone: 'warning' },
realized: { label: '实际待确认', tone: 'warning' },
verified: { label: '财务已确认', tone: 'success' },
reversed: { label: '已冲回', tone: 'danger' },
rejected: { label: '已拒绝', tone: 'danger' },
expired: { label: '已到期', tone: 'muted' }
}
const ACTION_META = {
accept: { label: '接受机会', tone: 'primary', kind: 'opportunity' },
start: { label: '开始执行', tone: 'primary', kind: 'opportunity' },
reject: { label: '拒绝', tone: 'danger', kind: 'opportunity' },
expire: { label: '标记到期', tone: 'danger', kind: 'opportunity' },
record_realization: { label: '登记实际结果', tone: 'warning', kind: 'evidence_required' },
confirm: { label: '财务确认', tone: 'primary', kind: 'realization' },
reverse: { label: '冲回', tone: 'danger', kind: 'realization' }
}
const DIMENSION_LABELS = {
department: '部门',
project: '项目',
expense_type: '费用类型',
supplier: '供应商',
city: '城市',
owner: '负责人',
source: '来源'
}
const DATA_QUALITY_LABELS = {
pendingConfirmationCount: '待财务确认',
businessStateOnlyCount: '仅平台业务状态',
pendingDedupeCount: '待 canonical 去重',
missingFxCount: '汇率证据缺失',
missingEvidenceCount: '证据缺失',
actualOverEstimateCount: '实际超过预计'
}
const FILTER_KEYS = [
'departmentId',
'projectCode',
'expenseType',
'supplierId',
'city',
'ownerId',
'sourceType',
'valueKind',
'status'
]
export function createEmptyValueFilters() {
return Object.fromEntries(FILTER_KEYS.map((key) => [key, '']))
}
export function readValueFiltersFromQuery(query = {}) {
const result = createEmptyValueFilters()
FILTER_KEYS.forEach((key) => {
const value = query[`value_${snakeCase(key)}`]
result[key] = Array.isArray(value) ? String(value[0] || '') : String(value || '')
})
return result
}
export function writeValueFiltersToQuery(query = {}, filters = {}) {
const result = { ...query }
FILTER_KEYS.forEach((key) => {
const queryKey = `value_${snakeCase(key)}`
const value = String(filters[key] || '').trim()
if (value) {
result[queryKey] = value
} else {
delete result[queryKey]
}
})
return result
}
export function resolveRangeWindow(activeRange, customRange = {}, nowValue = new Date()) {
const now = new Date(nowValue)
const end = new Date(now)
const start = new Date(now)
start.setHours(0, 0, 0, 0)
if (activeRange === 'custom' && customRange.start && customRange.end) {
const customStart = new Date(`${customRange.start}T00:00:00`)
const customEnd = new Date(`${customRange.end}T23:59:59.999`)
return {
start: customStart.toISOString(),
end: customEnd.toISOString()
}
}
if (activeRange === '本周') {
const day = start.getDay() || 7
start.setDate(start.getDate() - day + 1)
} else if (activeRange === '本月') {
start.setDate(1)
} else if (activeRange === '近10日') {
start.setDate(start.getDate() - 9)
}
return { start: start.toISOString(), end: end.toISOString() }
}
export function classifyCfoDashboardState({ dashboard, error, loading, now = new Date() } = {}) {
if (loading && !dashboard) return 'loading'
if (error?.status === 403) return 'permission'
if (error && !dashboard) return 'error'
if (!dashboard) return 'empty'
const source = dashboard.source || {}
const opportunityCount = Number(source.opportunityCount || 0)
const realizationCount = Number(source.realizationCount || 0)
if (source.dataStatus === 'empty' && opportunityCount === 0 && realizationCount === 0) {
return 'empty'
}
if (source.dataStatus === 'partial' || ((opportunityCount || realizationCount) && !source.freshnessAt)) {
return 'partial'
}
if (isDashboardStale(dashboard, now)) return 'stale'
return 'ready'
}
export function isDashboardStale(dashboard = {}, nowValue = new Date(), thresholdHours = 24) {
const freshness = dashboard.source?.freshnessAt
if (!freshness) return false
const timestamp = new Date(freshness).getTime()
const now = new Date(nowValue).getTime()
return Number.isFinite(timestamp) && Number.isFinite(now) && now - timestamp > thresholdHours * 3_600_000
}
export function buildValueKpis(dashboard = {}) {
const source = dashboard.source || {}
const cash = dashboard.kpis?.verifiedCash || {}
const labor = dashboard.kpis?.releasableLabor || {}
const safe = dashboard.kpis?.safeStraightThrough || {}
const valueKindFilter = String(dashboard.filters?.valueKind || '')
const hasLedgerRows = Number(source.opportunityCount || 0) + Number(source.realizationCount || 0) > 0
const cashValues = Array.isArray(cash.values) ? cash.values : []
const zeroCashLabel = resolveZeroCashLabel(dashboard)
const cashExcluded = valueKindFilter === 'labor'
const laborExcluded = valueKindFilter === 'cash'
return [
{
key: 'verified_cash',
label: cash.label || '财务确认净现金节省',
displayValue: cashExcluded ? '未纳入筛选' : (cashValues.length ? formatMoneyValues(cashValues) : (hasLedgerRows ? zeroCashLabel : '暂无数据')),
statusLabel: cashExcluded ? '筛选排除' : (cashValues.length ? '已核验' : (hasLedgerRows ? '真实零值' : '无台账数据')),
state: cashExcluded ? 'unavailable' : (cashValues.length ? 'available' : (hasLedgerRows ? 'zero' : 'empty')),
detail: cashExcluded
? '当前筛选仅查看工时价值,现金 KPI 未参与本次查询。'
: cashValues.length
? `${Number(cash.confirmedRealizationCount || 0)} 笔独立财务确认`
: (hasLedgerRows ? '当前周期尚无可计入主指标的确认结果' : '当前筛选范围暂无节省机会'),
definition: cash.definition || ''
},
{
key: 'labor',
label: labor.label || '财务确认可释放工时价值',
displayValue: laborExcluded ? '未纳入筛选' : '待采集',
statusLabel: laborExcluded ? '筛选排除' : (labor.status === 'unavailable' ? '基线缺失' : '采集中'),
state: laborExcluded ? 'unavailable' : 'baseline-missing',
detail: laborExcluded ? '当前筛选仅查看现金节省,工时价值未参与本次查询。' : (labor.reason || '缺少经过客户确认的工时基线。'),
requiredInputs: laborExcluded ? [] : (Array.isArray(labor.requiredInputs) ? labor.requiredInputs : [])
},
{
key: 'straight_through',
label: safe.label || '安全智能直通率',
displayValue: '待采集',
statusLabel: '审计样本不足',
state: 'baseline-missing',
detail: safe.reason || '缺少 eligibility 与事后审计结果。',
requiredInputs: Array.isArray(safe.requiredInputs) ? safe.requiredInputs : []
}
]
}
export function buildFunnelRows(dashboard = {}) {
const stages = Array.isArray(dashboard.funnel?.stages) ? dashboard.funnel.stages : []
const maxCount = Math.max(...stages.map((stage) => Number(stage.count || 0)), 1)
return stages.map((stage) => ({
...stage,
count: Number(stage.count || 0),
valueLabel: formatMoneyValues(stage.values),
width: `${Math.max((Number(stage.count || 0) / maxCount) * 100, stage.count ? 8 : 0)}%`
}))
}
export function buildTrendSeries(dashboard = {}, currency = '') {
const points = Array.isArray(dashboard.trend) ? dashboard.trend : []
const currencies = [...new Set(points.map((item) => String(item.currency || '')).filter(Boolean))]
const selectedCurrency = currencies.includes(currency) ? currency : (currencies[0] || '')
const rows = points
.filter((item) => !selectedCurrency || item.currency === selectedCurrency)
.map((item) => ({
period: item.period || '',
currency: item.currency || '',
verifiedNet: Number(item.verifiedNet || 0),
actualPending: Number(item.actualPending || 0),
reversal: Number(item.reversal || 0)
}))
return { currencies, selectedCurrency, rows }
}
export function buildBreakdownGroups(dashboard = {}) {
return (Array.isArray(dashboard.breakdowns) ? dashboard.breakdowns : []).map((group) => {
const items = Array.isArray(group.items) ? group.items : []
const normalized = items.flatMap((item) => splitBreakdownItemByCurrency(item))
const maxByCurrency = normalized.reduce((result, item) => {
result[item.currency] = Math.max(result[item.currency] || 0, item.verifiedMagnitude)
return result
}, {})
normalized.sort((left, right) => (
left.currency.localeCompare(right.currency)
|| right.verifiedMagnitude - left.verifiedMagnitude
|| left.dimensionName.localeCompare(right.dimensionName, 'zh-CN')
))
return {
...group,
label: DIMENSION_LABELS[group.dimension] || group.dimension,
items: normalized.map((item) => ({
...item,
width: `${Math.max((item.verifiedMagnitude / Math.max(maxByCurrency[item.currency] || 0, 1)) * 100, item.verifiedMagnitude ? 6 : 0)}%`
}))
}
})
}
export function buildGuardrailRows(dashboard = {}) {
return (Array.isArray(dashboard.guardrails) ? dashboard.guardrails : []).map((item) => ({
...item,
countLabel: item.count === null || item.count === undefined ? '不可用' : `${Number(item.count)}`,
valueLabel: formatMoneyValues(item.values),
rateLabel: item.rate === null || item.rate === undefined ? '' : formatPercent(item.rate),
statusLabel: item.status === 'ok' ? '正常' : (item.status === 'attention' ? '需关注' : '待接入')
}))
}
export function buildDataQualityRows(dashboard = {}) {
const quality = dashboard.dataQuality || {}
return Object.entries(DATA_QUALITY_LABELS).map(([key, label]) => {
const available = Object.hasOwn(quality, key)
const count = available ? Number(quality[key] || 0) : null
return {
key,
label,
count,
displayValue: available ? String(count) : '—',
tone: available ? (count > 0 ? 'attention' : 'ok') : 'unavailable'
}
})
}
export function buildOpportunityRows(payload = {}) {
return (Array.isArray(payload.items) ? payload.items : []).map((item) => ({
...item,
statusMeta: STATUS_META[item.status] || { label: item.status || '未知', tone: 'muted' },
estimatedLabel: formatMoney(item.estimatedNet, item.reportingCurrency || item.currency),
rangeLabel: `${formatMoney(item.estimatedLow, item.reportingCurrency || item.currency)} ~ ${formatMoney(item.estimatedHigh, item.reportingCurrency || item.currency)}`,
dueLabel: formatDateTime(item.dueAt),
updatedLabel: formatDateTime(item.updatedAt),
confidenceLabel: formatPercent(item.confidence),
ownerLabel: item.ownerName || item.ownerId || '未分配'
}))
}
export function resolveActionMeta(action) {
return ACTION_META[action] || { label: action || '执行动作', tone: 'primary', kind: 'opportunity' }
}
export function createRequestId(prefix = 'value') {
if (globalThis.crypto?.randomUUID) {
return `${prefix}-${globalThis.crypto.randomUUID()}`
}
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
}
export function formatMoneyValues(values = []) {
const rows = Array.isArray(values) ? values : []
if (!rows.length) return '—'
return rows.map((item) => formatMoney(item.amount, item.currency)).join(' / ')
}
export function formatMoney(amount, currency = 'CNY') {
const number = Number(amount || 0)
const normalizedCurrency = String(currency || 'CNY').toUpperCase()
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: normalizedCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(number)
} catch {
return `${normalizedCurrency} ${number.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`
}
}
export function formatPercent(value) {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
return `${(number * 100).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}%`
}
export function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
function splitBreakdownItemByCurrency(item = {}) {
const verifiedValues = Array.isArray(item.verifiedValues) ? item.verifiedValues : []
const estimatedValues = Array.isArray(item.estimatedValues) ? item.estimatedValues : []
const currencies = [...new Set(
[...verifiedValues, ...estimatedValues].map((value) => String(value.currency || '')).filter(Boolean)
)]
const rows = currencies.length ? currencies : ['']
return rows.map((currency) => {
const verified = verifiedValues.find((value) => value.currency === currency)
const estimated = estimatedValues.find((value) => value.currency === currency)
const verifiedAmount = Number(verified?.amount || 0)
return {
...item,
rowKey: `${item.dimensionId || item.dimensionName}:${currency || 'no-currency'}`,
currency,
verifiedLabel: currency ? formatMoney(verifiedAmount, currency) : '—',
estimatedLabel: currency ? formatMoney(estimated?.amount || 0, currency) : '—',
verifiedMagnitude: Math.abs(verifiedAmount)
}
})
}
function resolveZeroCashLabel(dashboard = {}) {
const currencies = new Set()
const stages = Array.isArray(dashboard.funnel?.stages) ? dashboard.funnel.stages : []
stages.forEach((stage) => {
const values = Array.isArray(stage.values) ? stage.values : []
values.forEach((item) => {
if (item.currency) currencies.add(String(item.currency))
})
})
if (!currencies.size) return '0无确认币种'
return [...currencies].map((currency) => formatMoney(0, currency)).join(' / ')
}
function snakeCase(value) {
return String(value || '').replace(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`)
}

View File

@@ -0,0 +1,242 @@
const VALUE_OPPORTUNITY_QUERY_KEY = 'value_opportunity'
const OPPORTUNITY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
const DIMENSION_LINKS = [
{ key: 'departmentId', dimensionKey: 'department_id', queryKey: 'value_department_id', label: '部门' },
{ key: 'projectCode', dimensionKey: 'project_code', queryKey: 'value_project_code', label: '项目' },
{ key: 'expenseType', dimensionKey: 'expense_type', queryKey: 'value_expense_type', label: '费用类型' },
{ key: 'supplierId', dimensionKey: 'supplier_id', queryKey: 'value_supplier_id', label: '供应商' },
{ key: 'city', dimensionKey: 'city', queryKey: 'value_city', label: '城市' }
]
export const BUDGET_CONFIGURATION_NOTICE = '预算配置视图只用于核对预算规则、范围与阈值,不代表当前节省机会的真实预算金额。'
export function normalizeValueOpportunityId(value) {
const raw = Array.isArray(value) ? value[0] : value
const normalized = String(raw || '').trim()
return OPPORTUNITY_ID_PATTERN.test(normalized) ? normalized : ''
}
export function readValueOpportunityId(query = {}) {
return normalizeValueOpportunityId(query[VALUE_OPPORTUNITY_QUERY_KEY])
}
export function hasValueOpportunityQuery(query = {}) {
return Object.hasOwn(query, VALUE_OPPORTUNITY_QUERY_KEY)
}
export function writeValueOpportunityToQuery(query = {}, opportunityId = '') {
const result = { ...query }
const normalized = normalizeValueOpportunityId(opportunityId)
if (normalized) result[VALUE_OPPORTUNITY_QUERY_KEY] = normalized
else delete result[VALUE_OPPORTUNITY_QUERY_KEY]
return result
}
export function shouldClearValueOpportunityError(error) {
return [403, 404].includes(Number(error?.status || 0))
}
export function opportunityMatchesValueContext(opportunity, filters = {}, window = {}) {
if (!opportunity || typeof opportunity !== 'object') return false
const dimensions = opportunity.dimensionJson || {}
const comparisons = [
[filters.departmentId, dimensions.department_id],
[filters.projectCode, dimensions.project_code],
[filters.expenseType, dimensions.expense_type],
[filters.supplierId, dimensions.supplier_id],
[filters.city, dimensions.city],
[filters.ownerId, opportunity.ownerId],
[filters.sourceType, opportunity.sourceType],
[filters.valueKind, opportunity.valueKind],
[filters.status, opportunity.status]
]
if (comparisons.some(([expected, actual]) => expected && !sameFilterValue(expected, actual))) {
return false
}
const createdAt = new Date(opportunity.createdAt || '').getTime()
const start = window.start ? new Date(window.start).getTime() : Number.NaN
const end = window.end ? new Date(window.end).getTime() : Number.NaN
if ((Number.isFinite(start) || Number.isFinite(end)) && !Number.isFinite(createdAt)) return false
if (Number.isFinite(start) && createdAt < start) return false
if (Number.isFinite(end) && createdAt > end) return false
return true
}
export function buildCfoOpportunitySourceLinks(opportunity = {}, options = {}) {
const dimensions = opportunity.dimensionJson || {}
const evidence = collectEvidence(opportunity)
const riskResource = evidence.find((item) => isRiskResource(item.resourceType))
const claimResource = evidence.find((item) => isClaimResource(item.resourceType))
const claimId = safeRouteValue(opportunity.claimId || claimResource?.resourceId)
const expenseCaseId = safeRouteValue(opportunity.expenseCaseId)
const aiDecisionId = safeRouteValue(opportunity.aiDecisionId)
const riskObservationId = safeRouteValue(
riskResource?.resourceId || (isRiskSource(opportunity) ? opportunity.sourceId : '')
)
const returnQuery = buildValueDashboardReturnQuery(options.dashboardQuery, { includeOpportunity: true })
const links = []
if (claimId) {
const riskFocused = Boolean(riskObservationId || isRiskSource(opportunity))
links.push({
key: riskFocused ? 'risk-claim' : 'claim',
kind: 'document',
label: riskFocused ? '查看风险来源单据' : '查看关联单据',
description: buildClaimDescription(opportunity, expenseCaseId, riskFocused),
icon: riskFocused ? 'mdi mdi-shield-search-outline' : 'mdi mdi-file-search-outline',
to: {
name: 'app-document-detail',
params: { requestId: claimId },
query: compactQuery({
...returnQuery,
returnTo: 'value',
...(expenseCaseId ? { expense_case_id: expenseCaseId } : {}),
...(riskFocused ? { focus: 'risk' } : {}),
...(riskObservationId ? { risk_observation_id: riskObservationId } : {}),
...(aiDecisionId ? { ai_decision_id: aiDecisionId } : {})
}),
...(riskFocused ? { hash: '#risk-observation-active-detail' } : {})
}
})
}
const departmentId = safeRouteValue(dimensions.department_id)
const departmentName = safeRouteValue(dimensions.department_name)
const expenseType = safeRouteValue(dimensions.expense_type)
if (departmentId || departmentName || expenseType) {
links.push({
key: 'budget-configuration',
kind: 'budget',
label: '查看预算配置视图',
description: BUDGET_CONFIGURATION_NOTICE,
icon: 'mdi mdi-wallet-outline',
to: {
name: 'app-budget',
query: compactQuery({
budget_view: 'configuration',
budget_department_id: departmentId,
budget_department_name: departmentName,
budget_expense_type: expenseType
})
}
})
}
links.push(...buildDimensionLinks(opportunity, options.dashboardQuery))
return links
}
export function readBudgetConfigurationFocus(query = {}) {
const view = readQueryValue(query.budget_view)
return {
active: view === 'configuration',
departmentId: readQueryValue(query.budget_department_id),
departmentName: readQueryValue(query.budget_department_name),
expenseType: readQueryValue(query.budget_expense_type)
}
}
function buildDimensionLinks(opportunity, dashboardQuery = {}) {
const dimensions = opportunity.dimensionJson || {}
const baseQuery = buildValueDashboardReturnQuery(dashboardQuery, { includeOpportunity: false })
delete baseQuery.value_page
const links = DIMENSION_LINKS.flatMap((definition) => {
const value = safeRouteValue(dimensions[definition.dimensionKey])
if (!value) return []
return [{
key: `dimension-${definition.key}`,
kind: 'dimension',
label: `${definition.label}查看 CFO 分析`,
description: `返回经营价值看板,并筛选${definition.label}${value}”。`,
icon: 'mdi mdi-chart-box-outline',
to: {
name: 'app-overview',
query: { ...baseQuery, [definition.queryKey]: value }
}
}]
})
const ownerId = safeRouteValue(opportunity.ownerId)
if (ownerId) {
links.push(buildTopLevelDimensionLink('owner', '负责人', 'value_owner_id', ownerId, baseQuery))
}
const sourceType = safeRouteValue(opportunity.sourceType)
if (sourceType) {
links.push(buildTopLevelDimensionLink('source', '来源', 'value_source_type', sourceType, baseQuery))
}
return links
}
function buildTopLevelDimensionLink(key, label, queryKey, value, baseQuery) {
return {
key: `dimension-${key}`,
kind: 'dimension',
label: `${label}查看 CFO 分析`,
description: `返回经营价值看板,并筛选${label}${value}”。`,
icon: 'mdi mdi-chart-box-outline',
to: { name: 'app-overview', query: { ...baseQuery, [queryKey]: value } }
}
}
function buildValueDashboardReturnQuery(query = {}, { includeOpportunity } = {}) {
const result = {}
Object.entries(query || {}).forEach(([key, value]) => {
const isOverviewWindow = ['range', 'start', 'end'].includes(key)
const isValueQuery = key.startsWith('value_')
if (isOverviewWindow || (isValueQuery && (includeOpportunity || key !== VALUE_OPPORTUNITY_QUERY_KEY))) {
result[key] = value
}
})
result.dashboard = 'value'
return result
}
function buildClaimDescription(opportunity, expenseCaseId, riskFocused) {
const claimLabel = safeRouteValue(opportunity.claimNoSnapshot || opportunity.claimId) || '关联单据'
const caseSuffix = expenseCaseId ? `,费用案件 ${expenseCaseId}` : ''
return riskFocused
? `打开 ${claimLabel} 的风险证据位置${caseSuffix}`
: `打开 ${claimLabel} 核对业务来源${caseSuffix}`
}
function collectEvidence(opportunity = {}) {
const rows = [
...(Array.isArray(opportunity.evidence) ? opportunity.evidence : []),
...(Array.isArray(opportunity.evidenceJson) ? opportunity.evidenceJson : [])
]
return rows.map((item) => ({
resourceType: String(item?.resourceType || item?.resource_type || '').trim(),
resourceId: String(item?.resourceId || item?.resource_id || '').trim()
}))
}
function isRiskResource(value) {
return /risk|observation/iu.test(String(value || ''))
}
function isClaimResource(value) {
return /^(expense_)?claim$|reimbursement|application_document/iu.test(String(value || ''))
}
function isRiskSource(opportunity = {}) {
return /risk|observation/iu.test(`${opportunity.sourceType || ''} ${opportunity.category || ''}`)
}
function sameFilterValue(left, right) {
return String(left || '').trim().toLocaleLowerCase('zh-CN') === String(right || '').trim().toLocaleLowerCase('zh-CN')
}
function safeRouteValue(value) {
const normalized = String(value || '').trim()
return normalized && normalized.length <= 160 ? normalized : ''
}
function readQueryValue(value) {
return safeRouteValue(Array.isArray(value) ? value[0] : value)
}
function compactQuery(query = {}) {
return Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined && value !== null))
}

View File

@@ -0,0 +1,12 @@
export function formatReceiptRecognitionScore(value) {
const score = Number(value || 0)
if (!Number.isFinite(score) || score <= 0) return '待确认'
return `${Math.round(score * 100)}%`
}
export function formatReceiptDateTime(value) {
if (!String(value ?? '').trim()) return '待确认'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '待确认'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}

View File

@@ -112,3 +112,88 @@ export function formatStewardFieldDisplayValue(field, value) {
const normalizedValue = String(value || '').trim()
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
}
export function buildStewardFieldItems(fields = [], taskType = '') {
const safeFields = filterStewardBlockingMissingFields(fields, taskType)
const seen = new Set()
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
.map((field) => resolveFieldDisplay(field, taskType))
}
export function formatStewardMissingFieldList(fields = [], taskType = '', options = {}) {
const includeHints = options.includeHints !== false
return buildStewardFieldItems(fields, taskType)
.map((item) => includeHints && item.hint ? `${item.label}${item.hint}` : item.label)
.join('、')
}
export function filterStewardBlockingMissingFields(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const seen = new Set()
if (taskType !== 'expense_application') {
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
}
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field) || APPLICATION_NON_BLOCKING_MISSING_FIELDS.has(field)) {
return false
}
seen.add(field)
return true
})
}
export function formatStewardOntologyFields(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return `${field.label}${formatStewardFieldDisplayValue(field.key, value)}`
})
.join('')
}
function buildStewardOntologyFieldRows(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return {
label: field.label,
value: formatStewardFieldDisplayValue(field.key, value)
}
})
}
function escapeMarkdownTableCell(value) {
return String(value || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
}
export function formatStewardOntologyFieldsTable(fields = {}, taskType = '') {
const rows = buildStewardOntologyFieldRows(fields, taskType)
if (!rows.length) {
return ''
}
return [
'| 字段 | 内容 |',
'| --- | --- |',
...rows.map((row) => `| ${escapeMarkdownTableCell(row.label)} | ${escapeMarkdownTableCell(row.value)} |`)
].join('\n')
}

View File

@@ -7,13 +7,21 @@ import {
SESSION_TYPE_EXPENSE
} from './travelReimbursementConversationModel.js'
import {
APPLICATION_NON_BLOCKING_MISSING_FIELDS,
FLOW_EXPENSE_TYPE_LABELS,
formatStewardFieldDisplayValue,
normalizeFieldKey,
resolveFieldDisplay
buildStewardFieldItems,
filterStewardBlockingMissingFields,
formatStewardMissingFieldList,
formatStewardOntologyFields,
formatStewardOntologyFieldsTable
} from './stewardPlanFields.js'
export {
buildStewardFieldItems,
filterStewardBlockingMissingFields,
formatStewardMissingFieldList,
formatStewardOntologyFields
}
const TASK_TYPE_LABELS = {
expense_application: '费用申请',
reimbursement: '费用报销',
@@ -198,91 +206,6 @@ export function buildStewardPlanMessageText(plan) {
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
}
export function buildStewardFieldItems(fields = [], taskType = '') {
const safeFields = filterStewardBlockingMissingFields(fields, taskType)
const seen = new Set()
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
.map((field) => resolveFieldDisplay(field, taskType))
}
export function formatStewardMissingFieldList(fields = [], taskType = '', options = {}) {
const includeHints = options.includeHints !== false
return buildStewardFieldItems(fields, taskType)
.map((item) => includeHints && item.hint ? `${item.label}${item.hint}` : item.label)
.join('、')
}
export function filterStewardBlockingMissingFields(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const seen = new Set()
if (taskType !== 'expense_application') {
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
}
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field) || APPLICATION_NON_BLOCKING_MISSING_FIELDS.has(field)) {
return false
}
seen.add(field)
return true
})
}
export function formatStewardOntologyFields(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return `${field.label}${formatStewardFieldDisplayValue(field.key, value)}`
})
.join('')
}
function buildStewardOntologyFieldRows(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return {
label: field.label,
value: formatStewardFieldDisplayValue(field.key, value)
}
})
}
function escapeMarkdownTableCell(value) {
return String(value || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
}
function formatStewardOntologyFieldsTable(fields = {}, taskType = '') {
const rows = buildStewardOntologyFieldRows(fields, taskType)
if (!rows.length) {
return ''
}
return [
'| 字段 | 内容 |',
'| --- | --- |',
...rows.map((row) => `| ${escapeMarkdownTableCell(row.label)} | ${escapeMarkdownTableCell(row.value)} |`)
].join('\n')
}
function resolveCandidateFlowExpenseType(flow = {}) {
const rawType = String(flow?.ontologyFields?.expense_type || flow?.ontologyFields?.expenseType || '').trim()
if (rawType === '差旅' || rawType === 'travel') {

View File

@@ -0,0 +1,243 @@
import { ref, watch } from 'vue'
import {
fetchAgentAssetReleaseReviewQueue,
fetchAgentAssetReleaseState,
labelAgentAssetReleaseObservation
} from '../../services/agentAssets.js'
function requestId(assetId, observationId, label) {
const suffix = typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
return `release-review:${assetId}:${observationId}:${label}:${suffix}`.slice(0, 160)
}
export function buildReleaseMonitorMetricCards(reviewQueue = {}) {
const metrics = reviewQueue?.metrics || {}
const recall = metricNumber(metrics.recall)
const recallLowerBound = metricNumber(metrics.recall_lower_bound)
const groundTruthStatus = String(metrics.negative_ground_truth_status || '').trim()
const recallReady = groundTruthStatus.startsWith('available_')
&& recall !== null
&& recallLowerBound !== null
const confidence = metricNumber(metrics.recall_confidence_level)
const falseNegativeCount = metricNumber(metrics.false_negative_count)
const estimatedFalseNegativeCount = metricNumber(metrics.estimated_false_negative_count)
const falseNegativeUpperBound = metricNumber(metrics.false_negative_upper_bound)
return [
{ label: '运行样本', value: countLabel(metrics.observed_count), hint: '真实执行次数' },
{ label: '待盲审', value: countLabel(reviewQueue.pending_total), hint: '正负样本随机混排' },
{
label: '负样本池',
value: countLabel(metrics.negative_sample_count),
hint: `已标注 ${countLabel(metrics.negative_labeled_count)}`
},
{
label: '负样本标注进度',
value: progress(metrics.negative_labeled_count, metrics.negative_sample_count),
hint: progressHint(metrics.negative_labeled_count, metrics.negative_sample_count)
},
{
label: '负样本积压',
value: countLabel(metrics.negative_pending_label_count),
hint: '盲审完成前不自动晋级'
},
{
label: '随机负样本抽检',
value: progress(metrics.random_negative_labeled_count, metrics.random_negative_sample_count),
hint: randomSampleHint(metrics)
},
{
label: '运行失败',
value: countLabel(metrics.runtime_failure_count),
hint: `失败率 ${percent(metrics.runtime_failure_rate)}`
},
{
label: '实际漏检',
value: nullableNumber(falseNegativeCount),
hint: falseNegativeCount === null
? '负样本真值未就绪,不按 0 展示'
: '独立盲审确认的真实漏检'
},
{
label: '估计漏检',
value: nullableNumber(estimatedFalseNegativeCount),
hint: falseNegativeUpperBound === null
? '保守上界不可用'
: `保守上界 ${decimal(falseNegativeUpperBound)}`
},
{
label: '召回率',
value: recallReady ? percent(recall) : '不可用',
hint: recallReady
? `保守下界 ${percent(recallLowerBound)}${confidence === null ? '' : ` · ${(confidence * 100).toFixed(0)}% 置信度`} · ${recallMethodLabel(metrics.recall_method)}`
: negativeGroundTruthHint(groundTruthStatus)
}
]
}
export function buildReleaseReviewDocumentRoute(item = {}) {
const documentId = String(item?.source_document_id || '').trim()
if (!documentId || documentId.length > 160) return null
return { name: 'app-document-detail', params: { requestId: documentId } }
}
export function useAuditReleaseMonitor({ selectedSkill, canManageSelected, toast }) {
const releaseState = ref(null)
const releaseQueue = ref(null)
const releaseLoading = ref(false)
const releaseError = ref('')
const releaseAction = ref('')
let loadSequence = 0
function reset() {
releaseState.value = null
releaseQueue.value = null
releaseError.value = ''
releaseAction.value = ''
}
async function loadReleaseMonitor() {
const assetId = String(selectedSkill.value?.id || '').trim()
const stage = String(selectedSkill.value?.configJson?.release_guard?.stage || '').trim()
if (!assetId || !selectedSkill.value?.usesJsonRiskRule || !stage || stage === 'rolled_back') {
reset()
return
}
const sequence = ++loadSequence
releaseLoading.value = true
releaseError.value = ''
try {
const [state, queue] = await Promise.all([
fetchAgentAssetReleaseState(assetId),
fetchAgentAssetReleaseReviewQueue(assetId)
])
if (sequence !== loadSequence || selectedSkill.value?.id !== assetId) return
releaseState.value = state
releaseQueue.value = queue
} catch (error) {
if (sequence !== loadSequence) return
releaseError.value = error?.message || '发布遥测加载失败,请稍后重试。'
} finally {
if (sequence === loadSequence) releaseLoading.value = false
}
}
async function submitReleaseLabel(observationId, label) {
const assetId = String(selectedSkill.value?.id || '').trim()
if (!assetId || !canManageSelected.value || releaseAction.value) return
releaseAction.value = `${observationId}:${label}`
try {
const result = await labelAgentAssetReleaseObservation(assetId, observationId, label, {
requestId: requestId(assetId, observationId, label)
})
toast(
label === 'risk_present'
? '已提交“存在真实风险”,发布指标已重新聚合。'
: '已提交“确认无该风险”,发布指标已重新聚合。'
)
await loadReleaseMonitor()
return result
} catch (error) {
toast(error?.message || '发布复核提交失败,请稍后重试。')
return null
} finally {
releaseAction.value = ''
}
}
watch(
() => [
selectedSkill.value?.id || '',
selectedSkill.value?.configJson?.release_guard?.release_id || '',
selectedSkill.value?.configJson?.release_guard?.stage || '',
selectedSkill.value?.loading ? '1' : '0'
],
() => {
if (selectedSkill.value?.loading) return
void loadReleaseMonitor()
},
{ immediate: true }
)
return {
releaseState,
releaseQueue,
releaseLoading,
releaseError,
releaseAction,
loadReleaseMonitor,
submitReleaseLabel
}
}
function metricNumber(value) {
if (value === null || typeof value === 'undefined' || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function countLabel(value) {
const parsed = metricNumber(value)
return parsed === null ? '不可用' : Math.max(0, parsed).toLocaleString('zh-CN')
}
function nullableNumber(value) {
return value === null ? '不可用' : decimal(value)
}
function decimal(value) {
const parsed = metricNumber(value)
if (parsed === null) return '不可用'
return parsed.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
function percent(value) {
const parsed = metricNumber(value)
return parsed === null ? '不可用' : `${(parsed * 100).toFixed(1)}%`
}
function progress(completed, total) {
const completedCount = metricNumber(completed)
const totalCount = metricNumber(total)
if (completedCount === null || totalCount === null || totalCount <= 0) return '不可用'
return percent(Math.min(Math.max(completedCount / totalCount, 0), 1))
}
function progressHint(completed, total) {
const completedCount = metricNumber(completed)
const totalCount = metricNumber(total)
if (completedCount === null || totalCount === null || totalCount <= 0) {
return '负样本证据未就绪'
}
const completedLabel = countLabel(completed)
const totalLabel = countLabel(total)
return `${completedLabel}/${totalLabel} 已完成`
}
function randomSampleHint(metrics = {}) {
const sampleCount = metricNumber(metrics.random_negative_sample_count)
if (sampleCount === null || sampleCount <= 0) return '随机负样本证据未就绪'
const sample = countLabel(metrics.random_negative_sample_count)
const labeled = countLabel(metrics.random_negative_labeled_count)
const population = countLabel(metrics.random_negative_population_count)
if ([sample, labeled, population].includes('不可用')) return '随机负样本证据未就绪'
return `抽检 ${labeled}/${sample} · 总体 ${population}`
}
function negativeGroundTruthHint(status) {
return {
available_stratified_random_audit: '负样本证据已就绪,但当前没有可计算分母',
collecting_candidate_labels: '正负样本标签采集中',
insufficient_random_negative_reviews: '随机负样本复核不足',
unavailable: '负样本证据未就绪'
}[status] || '负样本证据未就绪'
}
function recallMethodLabel(method) {
return method === 'stratified_random_audit_wilson_upper_bound'
? '分层随机盲审 Wilson 保守上界'
: '独立负样本盲审'
}

View File

@@ -0,0 +1,204 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
labelAgentAssetReleaseObservation,
normalizeAgentAssetReleaseReviewLabel,
normalizeAgentAssetReleaseReviewQueue
} from '../src/services/agentAssets.js'
import {
buildReleaseMonitorMetricCards,
buildReleaseReviewDocumentRoute
} from '../src/views/scripts/useAuditReleaseMonitor.js'
function source(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const panel = source('../src/components/audit/AuditReleaseMonitorPanel.vue')
const detail = source('../src/components/audit/AuditJsonRiskRuleDetail.vue')
const view = source('../src/views/AuditView.vue')
const service = source('../src/services/agentAssets.js')
const composable = source('../src/views/scripts/useAuditReleaseMonitor.js')
test('盲审面板正负样本混排且不读取或暗示模型预测', () => {
assert.match(panel, /正负样本随机混排/u)
assert.match(panel, /候选与基线结论对复核人隐藏/u)
assert.match(panel, /模型结论已隐藏/u)
assert.match(panel, /存在真实风险/u)
assert.match(panel, /确认无该风险/u)
assert.doesNotMatch(panel, /candidate_hit|baseline_hit/u)
assert.doesNotMatch(service, /candidate_hit|baseline_hit/u)
assert.doesNotMatch(panel, /'confirmed'|'false_positive'/u)
assert.match(panel, /'risk_present'/u)
assert.match(panel, /'risk_absent'/u)
assert.doesNotMatch(panel, /确认命中|标记误报|待复核正例/u)
assert.match(panel, /class="review-decision"[\s\S]*class="review-decision"/u)
assert.match(panel, /:key="item\.sample_id \|\| item\.observation_id"/u)
assert.match(panel, /target="_blank"/u)
assert.match(panel, /rel="noopener noreferrer"/u)
})
test('复核队列只保留盲审字段并可从 source_document_id 打开单据', () => {
const queue = normalizeAgentAssetReleaseReviewQueue({
asset_id: 'asset-1',
release_id: 'release-1',
stage: 'shadow',
version: 'v2',
pending_total: 1,
telemetry_status: 'collecting',
items: [{
sample_id: 'sample-1',
observation_id: 'observation-1',
source_document_id: 'claim-1',
rule_code: 'RISK-001',
business_stage: 'reimbursement',
prediction_blinded: true,
reviewer_count: 1,
required_reviewers: 2,
conflicted: false,
created_at: '2026-07-17T00:00:00Z',
candidate_hit: true,
baseline_hit: false
}]
})
assert.deepEqual(Object.keys(queue.items[0]).sort(), [
'business_stage', 'conflicted', 'created_at', 'observation_id', 'prediction_blinded',
'required_reviewers', 'reviewer_count', 'rule_code', 'sample_id', 'source_document_id'
])
assert.equal(queue.items[0].prediction_blinded, true)
assert.deepEqual(buildReleaseReviewDocumentRoute(queue.items[0]), {
name: 'app-document-detail',
params: { requestId: 'claim-1' }
})
assert.equal(buildReleaseReviewDocumentRoute({ source_document_id: '' }), null)
})
test('负样本证据未就绪时召回和实际漏检不可用null 不伪装为 0', () => {
const queue = normalizeAgentAssetReleaseReviewQueue({
pending_total: 4,
metrics: {
observed_count: 20,
runtime_failure_count: 1,
runtime_failure_rate: 0.05,
negative_sample_count: 8,
negative_labeled_count: 3,
negative_pending_label_count: 5,
false_negative_count: null,
estimated_false_negative_count: null,
false_negative_upper_bound: null,
random_negative_population_count: 12,
random_negative_sample_count: 4,
random_negative_labeled_count: 1,
recall: null,
recall_lower_bound: null,
recall_confidence_level: 0.95,
recall_method: 'stratified_random_audit_wilson_upper_bound',
negative_ground_truth_status: 'insufficient_random_negative_reviews'
}
})
const cards = cardsByLabel(buildReleaseMonitorMetricCards(queue))
assert.equal(queue.metrics.false_negative_count, null)
assert.equal(cards['实际漏检'].value, '不可用')
assert.match(cards['实际漏检'].hint, /不按 0 展示/u)
assert.equal(cards['估计漏检'].value, '不可用')
assert.equal(cards['召回率'].value, '不可用')
assert.equal(cards['召回率'].hint, '随机负样本复核不足')
assert.equal(cards['负样本积压'].value, '5')
assert.equal(cards['负样本标注进度'].value, '37.5%')
assert.equal(cards['随机负样本抽检'].value, '25.0%')
})
test('真实负样本证据显示召回点估计、保守下界和漏检上界', () => {
const cards = cardsByLabel(buildReleaseMonitorMetricCards({
pending_total: 2,
metrics: {
observed_count: 100,
runtime_failure_count: 0,
runtime_failure_rate: 0,
negative_sample_count: 20,
negative_labeled_count: 18,
negative_pending_label_count: 2,
false_negative_count: 2,
estimated_false_negative_count: 2.5,
false_negative_upper_bound: 4.75,
random_negative_population_count: 40,
random_negative_sample_count: 10,
random_negative_labeled_count: 10,
recall: 0.8,
recall_lower_bound: 0.64,
recall_confidence_level: 0.95,
recall_method: 'stratified_random_audit_wilson_upper_bound',
negative_ground_truth_status: 'available_stratified_random_audit'
}
}))
assert.equal(cards['实际漏检'].value, '2')
assert.equal(cards['估计漏检'].value, '2.5')
assert.equal(cards['估计漏检'].hint, '保守上界 4.75')
assert.equal(cards['负样本标注进度'].value, '90.0%')
assert.equal(cards['负样本积压'].value, '2')
assert.equal(cards['召回率'].value, '80.0%')
assert.match(cards['召回率'].hint, /保守下界 64\.0% · 95% 置信度/u)
assert.match(cards['召回率'].hint, /分层随机盲审 Wilson 保守上界/u)
})
test('发布复核使用独立租户鉴权 API、幂等请求号和中性结果文案', () => {
assert.match(service, /\/release\/review-queue/u)
assert.match(service, /\/release\/review-queue\/\$\{observationId\}\/labels/u)
assert.match(service, /normalizeAgentAssetReleaseReviewQueue\(payload\)/u)
assert.match(composable, /requestId:\s*requestId\(assetId, observationId, label\)/u)
assert.match(composable, /Promise\.all\(\[/u)
assert.match(composable, /fetchAgentAssetReleaseState/u)
assert.match(composable, /fetchAgentAssetReleaseReviewQueue/u)
assert.match(composable, /已提交“存在真实风险”/u)
assert.match(composable, /已提交“确认无该风险”/u)
assert.equal(normalizeAgentAssetReleaseReviewLabel('risk_present'), 'risk_present')
assert.equal(normalizeAgentAssetReleaseReviewLabel('risk_absent'), 'risk_absent')
assert.throws(() => normalizeAgentAssetReleaseReviewLabel('confirmed'), /risk_present/u)
assert.throws(() => normalizeAgentAssetReleaseReviewLabel('false_positive'), /risk_absent/u)
})
test('复核服务只发送 risk_present 或 risk_absent 新语义', async () => {
const originalFetch = globalThis.fetch
const bodies = []
globalThis.fetch = async (_url, options) => {
bodies.push(JSON.parse(options.body))
return new Response(JSON.stringify({ label: options.body.includes('risk_present') ? 'risk_present' : 'risk_absent' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
try {
await labelAgentAssetReleaseObservation('asset-1', 'observation-1', 'risk_present', {
actor: 'auditor', requestId: 'blind-review-present'
})
await labelAgentAssetReleaseObservation('asset-1', 'observation-2', 'risk_absent', {
actor: 'auditor', requestId: 'blind-review-absent'
})
assert.throws(
() => labelAgentAssetReleaseObservation('asset-1', 'observation-3', 'confirmed'),
/risk_present/u
)
} finally {
globalThis.fetch = originalFetch
}
assert.deepEqual(bodies, [{ label: 'risk_present' }, { label: 'risk_absent' }])
})
test('规则详情和审计页复用独立发布监控组件', () => {
assert.match(detail, /<AuditReleaseMonitorPanel/u)
assert.match(detail, /@label="\(observationId, label\) => emit\('label-release'/u)
assert.match(view, /:release-queue="releaseQueue"/u)
assert.match(view, /@label-release="submitReleaseLabel"/u)
})
function cardsByLabel(cards) {
return Object.fromEntries(cards.map((card) => [card.label, card]))
}

View File

@@ -116,12 +116,16 @@ test('documents center uses the full request list instead of the global date-fil
})
test('workbench summary merges approval inbox requests without polluting document center rows', () => {
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(
appShellComposable,
/import \{\s*REIMBURSEMENT_LIST_PREVIEW_PARAMS,\s*extractExpenseClaimItems,\s*fetchApprovalExpenseClaims,\s*fetchExpenseClaimDetail\s*\} from '\.\.\/services\/reimbursements\.js'/
)
assert.match(appShellComposable, /const workbenchApprovalRequests = ref\(\[\]\)/)
assert.match(appShellComposable, /async function reloadWorkbenchApprovalRequests\(\)/)
assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\)/)
assert.match(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(appShellComposable, /payload\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
assert.match(appShellComposable, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(appShellComposable, /extractExpenseClaimItems\(payload\)\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
assert.doesNotMatch(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(appShellComposable, /Promise\.all\(\[[\s\S]*reloadRequests\(\{ silent: true \}\),[\s\S]*reloadWorkbenchApprovalRequests\(\)[\s\S]*\]\)/)
assert.match(appShellComposable, /if \(view === 'workbench'\) \{[\s\S]*void reloadWorkbenchRequests\(\)/)
assert.match(appShellComposable, /const workbenchRequests = computed\(\(\) =>[\s\S]*mergeWorkbenchRequests\(requests\.value, workbenchApprovalRequests\.value\)/)
@@ -161,7 +165,10 @@ test('document detail navigation preserves document center list query', () => {
})
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(
appShellComposable,
/import \{\s*REIMBURSEMENT_LIST_PREVIEW_PARAMS,\s*extractExpenseClaimItems,\s*fetchApprovalExpenseClaims,\s*fetchExpenseClaimDetail\s*\} from '\.\.\/services\/reimbursements\.js'/
)
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)

View File

@@ -71,10 +71,10 @@ test('claim delete flow invalidates the matching financial assistant session', (
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const createViewScript = [
'../src/views/scripts/TravelReimbursementCreateView.js',
'../src/views/scripts/useTravelReimbursementCreateViewSessionCleanup.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
assert.match(appShellScript, /clearAssistantSessionSnapshotForDraftClaim/)
assert.match(appShellScript, /async function handleRequestDeleted\(payload = \{\}\)/)
@@ -194,8 +194,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
})
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
const detailExpenseEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailExpenseEditor.js', import.meta.url)),
'utf8'
)
const smartEntryRecognitionScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSmartEntryRecognition.js', import.meta.url)),
'utf8'
)
const appShellScript = readFileSync(
@@ -206,13 +210,17 @@ test('detail smart entry is scoped to the current claim instead of the latest co
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const submitComposerScript = [
'../src/views/scripts/useTravelReimbursementSubmitComposer.js',
'../src/views/scripts/travelReimbursementSubmitApplicationPreview.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
assert.match(detailViewScript, /restoreLatestConversation:\s*false/)
assert.match(detailViewScript, /scope:\s*claimId[\s\S]*type:\s*'claim'[\s\S]*claimId/)
assert.match(detailExpenseEditorScript, /if \(!request\.value\.claimId\) \{[\s\S]*当前草稿缺少 claimId/)
assert.match(detailExpenseEditorScript, /startSmartEntryRecognitionTask\(\{[\s\S]*claimId:\s*request\.value\.claimId/)
assert.match(detailExpenseEditorScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
assert.match(smartEntryRecognitionScript, /const normalizedClaimId = normalizeSmartEntryClaimId\(claimId\)/)
assert.match(smartEntryRecognitionScript, /smartEntryRecognitionTasks\.set\(normalizedClaimId, task\)/)
assert.doesNotMatch(detailExpenseEditorScript, /restoreLatestConversation/)
assert.match(appShellScript, /function isDetailClaimScopedPayload\(payload = \{\}\)/)
assert.match(appShellScript, /if \(isDetailClaimScopedPayload\(payload\)\) \{[\s\S]*return null[\s\S]*\}/)
assert.match(sessionStateScript, /const shouldPersistLocalSnapshot = props\.entrySource !== 'detail'/)

View File

@@ -0,0 +1,426 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildCfoValueSearch,
buildSavingsOpportunitySearch,
normalizeAnalyticsValuePayload,
recordSavingsRealization
} from '../src/services/analyticsValue.js'
import {
buildBreakdownGroups,
buildDataQualityRows,
buildValueKpis,
classifyCfoDashboardState,
formatMoneyValues,
isDashboardStale,
readValueFiltersFromQuery,
resolveRangeWindow,
writeValueFiltersToQuery
} from '../src/views/scripts/cfoValueDashboardModel.js'
import {
BUDGET_CONFIGURATION_NOTICE,
buildCfoOpportunitySourceLinks,
hasValueOpportunityQuery,
normalizeValueOpportunityId,
opportunityMatchesValueContext,
readBudgetConfigurationFocus,
readValueOpportunityId,
shouldClearValueOpportunityError,
writeValueOpportunityToQuery
} from '../src/views/scripts/cfoValueSourceLinks.js'
const overviewView = readSource('../src/views/OverviewView.vue')
const topBarRange = readSource('../src/components/layout/useTopBarOverviewRange.js')
const appShell = readSource('../src/views/AppShellRouteView.vue')
const appShellComposable = readSource('../src/composables/useAppShell.js')
const budgetCenter = readSource('../src/views/BudgetCenterView.vue')
const budgetCenterScript = readSource('../src/views/scripts/BudgetCenterView.js')
const dashboardComponent = readSource('../src/components/dashboard/CfoValueDashboard.vue')
const dashboardComposable = readSource('../src/composables/useCfoValueDashboard.js')
const dashboardModel = readSource('../src/views/scripts/cfoValueDashboardModel.js')
const valueService = readSource('../src/services/analyticsValue.js')
const actionDialog = readSource('../src/components/dashboard/CfoValueActionDialog.vue')
const opportunityDrawer = readSource('../src/components/dashboard/CfoValueOpportunityDrawer.vue')
const trendChart = readSource('../src/components/charts/CfoValueTrendChart.vue')
const dashboardStyles = readSource('../src/assets/styles/components/cfo-value-dashboard.css')
test('经营价值接口把后端 snake_case 契约规范为前端字段且保留币种分账', () => {
const normalized = normalizeAnalyticsValuePayload({
source: { data_status: 'complete', opportunity_count: 2 },
funnel: { realization_rate_by_currency: { CNY: '0.2500', USD: null } },
data_quality: { pending_confirmation_count: 1 }
})
assert.equal(normalized.source.dataStatus, 'complete')
assert.equal(normalized.source.opportunityCount, 2)
assert.equal(normalized.funnel.realizationRateByCurrency.CNY, '0.2500')
assert.equal(normalized.funnel.realizationRateByCurrency.USD, null)
assert.equal(normalized.dataQuality.pendingConfirmationCount, 1)
})
test('经营价值与机会台账查询只发送真实筛选字段', () => {
const dashboardSearch = buildCfoValueSearch({
start: '2026-07-01T00:00:00.000Z',
end: '2026-07-16T00:00:00.000Z',
departmentId: 'dept-1',
projectCode: '',
valueKind: 'cash'
})
const opportunitySearch = buildSavingsOpportunitySearch({
page: 2,
pageSize: 12,
status: 'in_progress',
ownerId: 'finance-1'
})
assert.equal(dashboardSearch.get('department_id'), 'dept-1')
assert.equal(dashboardSearch.get('value_kind'), 'cash')
assert.equal(dashboardSearch.has('project_code'), false)
assert.equal(opportunitySearch.get('page_size'), '12')
assert.equal(opportunitySearch.get('status'), 'in_progress')
assert.equal(opportunitySearch.get('owner_id'), 'finance-1')
})
test('看板明确区分 loading permission error empty partial stale 与 ready', () => {
const complete = dashboardFixture({ dataStatus: 'complete' })
const partial = dashboardFixture({ dataStatus: 'partial' })
const empty = dashboardFixture({ dataStatus: 'empty', opportunityCount: 0, realizationCount: 0 })
const stale = dashboardFixture({ dataStatus: 'complete', freshnessAt: '2026-07-01T00:00:00Z' })
assert.equal(classifyCfoDashboardState({ loading: true }), 'loading')
assert.equal(classifyCfoDashboardState({ error: { status: 403 } }), 'permission')
assert.equal(classifyCfoDashboardState({ error: new Error('offline') }), 'error')
assert.equal(classifyCfoDashboardState({ dashboard: empty }), 'empty')
assert.equal(classifyCfoDashboardState({ dashboard: partial }), 'partial')
assert.equal(classifyCfoDashboardState({ dashboard: dashboardFixture({ dataStatus: 'complete', freshnessAt: '' }) }), 'partial')
assert.equal(classifyCfoDashboardState({ dashboard: complete, now: new Date('2026-07-16T01:00:00Z') }), 'ready')
assert.equal(classifyCfoDashboardState({ dashboard: stale, now: new Date('2026-07-16T01:00:00Z') }), 'stale')
assert.equal(isDashboardStale(stale, new Date('2026-07-16T01:00:00Z')), true)
})
test('数据质量缺字段显示不可用而不是伪装成零缺口', () => {
const rows = buildDataQualityRows({ dataQuality: { pendingConfirmationCount: 0 } })
assert.deepEqual(rows[0], {
key: 'pendingConfirmationCount',
label: '待财务确认',
count: 0,
displayValue: '0',
tone: 'ok'
})
assert.equal(rows[1].count, null)
assert.equal(rows[1].displayValue, '—')
assert.equal(rows[1].tone, 'unavailable')
})
test('零节省、空台账与缺失工时基线不会混为默认数字', () => {
const dashboard = dashboardFixture({ dataStatus: 'complete', opportunityCount: 2, realizationCount: 1 })
dashboard.kpis = {
verifiedCash: { label: '财务确认净现金节省', status: 'empty', values: [], confirmedRealizationCount: 0 },
releasableLabor: { label: '财务确认可释放工时价值', status: 'collecting', reason: '缺少工时基线', requiredInputs: ['人工分钟'] },
safeStraightThrough: { label: '安全智能直通率', status: 'collecting', reason: '缺少审计样本', requiredInputs: ['审计结果'] }
}
dashboard.funnel = { stages: [{ key: 'estimated', values: [{ currency: 'USD', amount: '50.00' }] }] }
const [cash, labor, straightThrough] = buildValueKpis(dashboard)
assert.equal(cash.state, 'zero')
assert.match(cash.displayValue, /US\$0|\$0/u)
assert.equal(labor.state, 'baseline-missing')
assert.equal(labor.displayValue, '待采集')
assert.equal(straightThrough.displayValue, '待采集')
assert.match(
formatMoneyValues([{ currency: 'CNY', amount: '10' }, { currency: 'USD', amount: '20' }]),
/¥10 \/ (?:US)?\$20/u
)
dashboard.filters = { valueKind: 'labor' }
const [excludedCash] = buildValueKpis(dashboard)
assert.equal(excludedCash.state, 'unavailable')
assert.equal(excludedCash.displayValue, '未纳入筛选')
})
test('价值分解按币种拆行,绝不把不同币种相加后比较', () => {
const groups = buildBreakdownGroups({
breakdowns: [{
dimension: 'department',
items: [{
dimensionId: 'dept-1',
dimensionName: '财务部',
opportunityCount: 2,
verifiedValues: [
{ currency: 'CNY', amount: '100' },
{ currency: 'USD', amount: '20' }
],
estimatedValues: [
{ currency: 'CNY', amount: '180' },
{ currency: 'USD', amount: '30' }
]
}]
}]
})
assert.equal(groups[0].items.length, 2)
assert.deepEqual(groups[0].items.map((item) => item.currency), ['CNY', 'USD'])
assert.deepEqual(groups[0].items.map((item) => item.verifiedMagnitude), [100, 20])
assert.ok(groups[0].items.every((item) => item.width === '100%'))
})
test('筛选条件和时间窗口可以通过 URL 恢复', () => {
const filters = readValueFiltersFromQuery({
value_department_id: 'dept-7',
value_value_kind: 'cash',
value_status: 'verified'
})
assert.deepEqual(
{ departmentId: filters.departmentId, valueKind: filters.valueKind, status: filters.status },
{ departmentId: 'dept-7', valueKind: 'cash', status: 'verified' }
)
const query = writeValueFiltersToQuery({ dashboard: 'value', value_city: 'old' }, {
...filters,
city: ''
})
assert.equal(query.dashboard, 'value')
assert.equal(query.value_department_id, 'dept-7')
assert.equal(Object.hasOwn(query, 'value_city'), false)
const range = resolveRangeWindow('custom', { start: '2026-07-01', end: '2026-07-16' })
assert.match(range.start, /^2026-06-30T16:00:00\.000Z$|^2026-07-01T00:00:00\.000Z$/u)
assert.ok(new Date(range.end) > new Date(range.start))
})
test('机会抽屉 URL 可恢复、关闭可清理且非法 ID 不会残留', () => {
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
const opened = writeValueOpportunityToQuery({ dashboard: 'value', value_city: '上海' }, opportunityId)
assert.equal(readValueOpportunityId(opened), opportunityId)
assert.equal(hasValueOpportunityQuery(opened), true)
const closed = writeValueOpportunityToQuery(opened)
assert.equal(hasValueOpportunityQuery(closed), false)
assert.equal(closed.value_city, '上海')
const malformed = { ...opened, value_opportunity: '../tenant-b/opportunity' }
assert.equal(normalizeValueOpportunityId(malformed.value_opportunity), '')
assert.equal(readValueOpportunityId(malformed), '')
assert.equal(hasValueOpportunityQuery(writeValueOpportunityToQuery(malformed)), false)
assert.equal(shouldClearValueOpportunityError({ status: 403 }), true)
assert.equal(shouldClearValueOpportunityError({ status: 404 }), true)
assert.equal(shouldClearValueOpportunityError({ status: 500 }), false)
})
test('打开机会必须仍属于当前筛选和时间窗口', () => {
const opportunity = {
departmentId: 'ignored-top-level',
dimensionJson: {
department_id: 'dept-7',
project_code: 'PROJECT-1',
expense_type: 'hotel',
supplier_id: 'supplier-1',
city: '上海'
},
ownerId: 'finance-1',
sourceType: 'risk_observation',
valueKind: 'cash',
status: 'identified',
createdAt: '2026-07-15T08:00:00Z'
}
const filters = {
departmentId: 'dept-7', projectCode: 'PROJECT-1', expenseType: 'hotel',
supplierId: 'supplier-1', city: '上海', ownerId: 'finance-1',
sourceType: 'risk_observation', valueKind: 'cash', status: 'identified'
}
const window = { start: '2026-07-01T00:00:00Z', end: '2026-07-16T23:59:59Z' }
assert.equal(opportunityMatchesValueContext(opportunity, filters, window), true)
assert.equal(opportunityMatchesValueContext(opportunity, { ...filters, departmentId: 'dept-other' }, window), false)
assert.equal(opportunityMatchesValueContext(opportunity, filters, { ...window, end: '2026-07-14T00:00:00Z' }), false)
})
test('来源动作可定位风险单据、预算配置和 CFO 维度且不伪造预算事实', () => {
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
const links = buildCfoOpportunitySourceLinks({
id: opportunityId,
claimId: '8a25e85a-b7d8-41dc-8717-57bd1d458fd2',
claimNoSnapshot: 'BX-20260716-001',
expenseCaseId: '04452d7a-cdb8-4665-bb9d-99b7f8fc7f1b',
aiDecisionId: 'b100ca12-8ce3-47eb-a80d-35883410545a',
sourceType: 'risk_observation',
sourceId: 'risk-fallback',
category: 'risk_avoidance',
ownerId: 'finance-1',
dimensionJson: {
department_id: 'dept-7',
department_name: '财务部',
project_code: 'PROJECT-1',
expense_type: 'hotel',
city: '上海'
},
evidence: [{ resourceType: 'risk_observation', resourceId: 'risk-observation-7' }]
}, {
dashboardQuery: {
dashboard: 'value',
range: '本月',
value_city: '上海',
value_opportunity: opportunityId
}
})
const risk = links.find((item) => item.key === 'risk-claim')
assert.equal(risk.label, '查看风险来源单据')
assert.equal(risk.to.name, 'app-document-detail')
assert.equal(risk.to.params.requestId, '8a25e85a-b7d8-41dc-8717-57bd1d458fd2')
assert.equal(risk.to.query.returnTo, 'value')
assert.equal(risk.to.query.focus, 'risk')
assert.equal(risk.to.query.risk_observation_id, 'risk-observation-7')
assert.equal(risk.to.query.ai_decision_id, 'b100ca12-8ce3-47eb-a80d-35883410545a')
assert.equal(risk.to.hash, '#risk-observation-active-detail')
const budget = links.find((item) => item.key === 'budget-configuration')
assert.equal(budget.label, '查看预算配置视图')
assert.equal(budget.to.name, 'app-budget')
assert.equal(budget.to.query.budget_department_id, 'dept-7')
assert.equal(budget.to.query.budget_expense_type, 'hotel')
assert.equal(budget.description, BUDGET_CONFIGURATION_NOTICE)
assert.match(budget.description, /不代表当前节省机会的真实预算金额/u)
const dimension = links.find((item) => item.key === 'dimension-expenseType')
assert.equal(dimension.to.name, 'app-overview')
assert.equal(dimension.to.query.dashboard, 'value')
assert.equal(dimension.to.query.value_expense_type, 'hotel')
assert.equal(Object.hasOwn(dimension.to.query, 'value_opportunity'), false)
assert.deepEqual(readBudgetConfigurationFocus(budget.to.query), {
active: true,
departmentId: 'dept-7',
departmentName: '财务部',
expenseType: 'hotel'
})
})
test('带可追溯凭证的实际结果递归序列化为后端 snake_case', async () => {
const originalFetch = globalThis.fetch
let requestBody = null
globalThis.fetch = async (_url, options) => {
requestBody = JSON.parse(options.body)
return new Response(JSON.stringify({ realization: {}, opportunity: {}, event: {}, replayed: false }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
try {
await recordSavingsRealization('opp-1', {
requestId: 'request-12345678',
expectedVersion: 2,
comment: '平台付款完成',
actualGross: '100',
incrementalCost: '10',
realizedAt: '2026-07-16T00:00:00Z',
evidenceLevel: 'external_document',
evidence: [{
evidenceKey: 'payment-receipt-001',
evidenceRole: 'payment_receipt',
resourceType: 'payment_receipt',
resourceId: 'receipt-001',
sourceSystem: 'erp',
contentHash: '0123456789abcdef0123456789abcdef',
occurredAt: '2026-07-16T00:00:00Z',
verificationStatus: 'unverified',
metadataJson: { connector: 'erp' }
}]
})
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requestBody.request_id, 'request-12345678')
assert.equal(requestBody.expected_version, 2)
assert.equal(requestBody.incremental_cost, '10')
assert.equal(requestBody.evidence_level, 'external_document')
assert.equal(requestBody.evidence[0].evidence_key, 'payment-receipt-001')
assert.equal(requestBody.evidence[0].metadata_json.connector, 'erp')
})
test('经营价值入口、独立模块、URL 深链与完整状态 UI 已接入分析看板', () => {
assert.match(topBarRange, /label: '经营价值看板', value: 'value'/u)
assert.match(overviewView, /<CfoValueDashboard/u)
assert.match(overviewView, /props\.dashboard === 'value'/u)
assert.match(appShell, /OVERVIEW_DASHBOARDS = new Set\(\['finance', 'value'/u)
assert.match(appShell, /dashboard: resolveOverviewDashboard\(overviewDashboard\.value\)/u)
assert.match(dashboardComposable, /readValueFiltersFromQuery\(route\.query\)/u)
assert.match(dashboardComposable, /writeValueFiltersToQuery\(route\.query/u)
assert.match(dashboardComponent, /dashboardState === 'loading'/u)
assert.match(dashboardComponent, /dashboardState === 'permission'/u)
assert.match(dashboardComponent, /dashboardState === 'error'/u)
assert.match(dashboardComponent, /dashboardState === 'empty'/u)
assert.match(dashboardComponent, /dashboardState === 'partial'/u)
assert.match(dashboardComponent, /dashboardStale/u)
assert.match(dashboardComponent, /kpi\.state/u)
assert.match(dashboardComponent, /机会状态 <em>仅筛选下方台账<\/em>/u)
assert.match(dashboardModel, /baseline-missing/u)
assert.match(dashboardModel, /真实零值/u)
assert.match(dashboardComposable, /valueOpportunityRouteSignature/u)
assert.match(dashboardComposable, /clearUnavailableOpportunity/u)
assert.match(dashboardComposable, /router\.replace\(\{ query: nextQuery \}\)/u)
assert.match(appShellComposable, /DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation', 'value'\]\)/u)
assert.match(appShellComposable, /name: 'app-overview', query: buildValueDashboardReturnQuery\(\)/u)
assert.match(opportunityDrawer, /id="value-source-links-title">查看来源/u)
assert.match(opportunityDrawer, /\{\{ link\.label \}\}/u)
assert.match(budgetCenter, /预算配置视图 · \{\{ budgetConfigurationFocusSummary \}\}/u)
assert.match(budgetCenter, /isFocusedBudgetCategory\(item\)/u)
assert.match(budgetCenterScript, /readBudgetConfigurationFocus\(route\.query\)/u)
assert.match(budgetCenterScript, /\.filter\(\(row\) => matchesBudgetConfigurationExpense\(row\)\)/u)
assert.match(budgetCenterScript, /这不代表预算为零/u)
})
test('经营价值实现不包含 demo 或 fallback 指标数字', () => {
assert.doesNotMatch(valueService, /FALLBACK|DEMO|mock/iu)
assert.doesNotMatch(dashboardComposable, /fallback|demo|mock/iu)
assert.match(dashboardComponent, /不会展示旧的演示数字/u)
})
test('移动端、键盘与读屏交互具备可验证的生产语义', () => {
assert.match(dashboardStyles, /@media \(max-width: 760px\)/u)
assert.match(dashboardStyles, /min-height: 44px/u)
assert.match(dashboardComponent, /role="alert"/u)
assert.match(dashboardComponent, /aria-live="polite"/u)
assert.match(actionDialog, /<label/u)
assert.match(actionDialog, /role="alert"/u)
assert.match(actionDialog, /:disabled="submitting"/u)
assert.match(actionDialog, /requestId: form\.requestId/u)
assert.match(dashboardComposable, /command\.requestId \|\| createRequestId/u)
assert.doesNotMatch(dashboardComposable, /recordSavingsRealization/u)
assert.match(dashboardComposable, /实际结果必须由付款事件或具备可追溯凭证的连接器写入/u)
assert.match(opportunityDrawer, /action !== 'record_realization'/u)
assert.match(opportunityDrawer, /当前页面不会提交空证据/u)
assert.match(opportunityDrawer, /financeConfirmerName/u)
assert.match(opportunityDrawer, /确认说明:/u)
assert.doesNotMatch(actionDialog, /实际毛节省/u)
assert.match(opportunityDrawer, /aria-label="关闭节省机会详情"/u)
assert.match(trendChart, /role="img"/u)
assert.match(trendChart, /查看趋势明细表/u)
assert.match(trendChart, /<caption class="sr-only">/u)
})
function dashboardFixture({
dataStatus,
opportunityCount = 1,
realizationCount = 1,
freshnessAt = '2026-07-16T00:00:00Z'
}) {
return {
source: { dataStatus, opportunityCount, realizationCount, freshnessAt },
kpis: {},
funnel: { stages: [] },
trend: [],
breakdowns: [],
guardrails: [],
dataQuality: {}
}
}
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}

View File

@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
const componentFiles = [
'../src/components/commercial/CommercialWorkspace.vue',
'../src/components/commercial/CommercialAccountOverview.vue',
'../src/components/commercial/CommercialValueProofPanel.vue',
'../src/components/commercial/CommercialPricingScenarioPanel.vue',
'../src/components/commercial/CommercialMetricCard.vue',
'../src/components/commercial/CommercialAdminConsole.vue',
'../src/components/commercial/CommercialHistoryPanel.vue',
'../src/components/commercial/CommercialMutationDialog.vue'
]
for (const relativeFile of componentFiles) {
test(`${relativeFile} 可以独立编译`, () => {
const filename = fileURLToPath(new URL(relativeFile, import.meta.url))
const source = readFileSync(filename, 'utf8')
const parsed = parse(source, { filename })
assert.deepEqual(parsed.errors, [])
const id = `commercial-${relativeFile.replace(/\W+/gu, '-')}`
const script = compileScript(parsed.descriptor, { id })
const template = compileTemplate({
source: parsed.descriptor.template?.content || '',
filename,
id,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
})
}
const workspace = readSource('../src/components/commercial/CommercialWorkspace.vue')
const valuePanel = readSource('../src/components/commercial/CommercialValueProofPanel.vue')
const pricingPanel = readSource('../src/components/commercial/CommercialPricingScenarioPanel.vue')
const metricCard = readSource('../src/components/commercial/CommercialMetricCard.vue')
const adminConsole = readSource('../src/components/commercial/CommercialAdminConsole.vue')
const mutationDialog = readSource('../src/components/commercial/CommercialMutationDialog.vue')
const historyPanel = readSource('../src/components/commercial/CommercialHistoryPanel.vue')
const styles = readSource('../src/components/commercial/commercial-workspace.css')
const overviewView = readSource('../src/views/OverviewView.vue')
const appShell = readSource('../src/views/AppShellRouteView.vue')
const topBarOverviewRange = readSource('../src/components/layout/useTopBarOverviewRange.js')
const overviewModel = readSource('../src/composables/useOverviewView.js')
test('商业工作台具有显式租户选择、双账分离、权限和完整空错状态', () => {
assert.match(workspace, /目标租户 ID/u)
assert.match(workspace, /平台管理员必须显式选择租户/u)
assert.match(workspace, /商业权益不能放宽安全规则/u)
assert.match(valuePanel, /客户价值账/u)
assert.match(valuePanel, /平台经营账/u)
assert.match(valuePanel, /不同币种不会混算/u)
assert.match(valuePanel, /!platformAdmin/u)
assert.match(valuePanel, /role="alert"/u)
assert.match(valuePanel, /这不是零成本或零价值/u)
})
test('无真实财务证据显示不可用和缺失输入,不含演示或回退指标', () => {
assert.match(metricCard, /commercial-unavailable-value">不可用/u)
assert.match(metricCard, /查看缺失证据/u)
assert.match(metricCard, /requiredInputs/u)
assert.doesNotMatch(workspace, /demo|mock|fallback/iu)
assert.doesNotMatch(valuePanel, /demo|mock|fallback/iu)
})
test('定价场景具有参数确认、分币种走廊、部分可用与不写套餐语义', () => {
assert.match(workspace, /CommercialPricingScenarioPanel/u)
for (const label of ['目标贡献毛利率', '最大价值分享比例', '证据截止时间as_of', '确认计算条件']) {
assert.match(pricingPanel, new RegExp(label, 'u'))
}
for (const label of ['可持续收费下限', '价值对齐收费上限', '成功费封顶', '推荐商业模式', '缺失输入']) {
assert.match(pricingPanel, new RegExp(label, 'u'))
}
assert.match(pricingPanel, /v-for="row in view\.rows"/u)
assert.match(pricingPanel, /row\.currency/u)
assert.match(pricingPanel, /evidenceStatus/u)
assert.match(pricingPanel, /不可用/u)
assert.match(pricingPanel, /不会自动写入套餐/u)
assert.match(pricingPanel, /aria-busy/u)
assert.match(pricingPanel, /role="alert"/u)
assert.match(pricingPanel, /inputmode="decimal"/u)
})
test('所有商业写操作都有操作确认、忙碌态、幂等和版本化提示', () => {
for (const label of ['创建套餐版本', '激活套餐版本', '创建订阅快照', '重新激活订阅', '变更订阅状态', '配置权益版本', '激活权益版本', '记录用量事件', '记录成本事件']) {
assert.match(readSource('../src/components/commercial/commercialWorkspaceModel.js'), new RegExp(label, 'u'))
}
assert.match(adminConsole, /二次确认/u)
assert.match(mutationDialog, /step === 'edit'/u)
assert.match(mutationDialog, /确认写入/u)
assert.match(mutationDialog, /幂等键/u)
assert.match(mutationDialog, /版本化快照/u)
assert.match(mutationDialog, /:disabled="busy"/u)
assert.match(mutationDialog, /role="alertdialog"/u)
})
test('五类历史有按需加载、刷新、空态与订阅状态操作入口', () => {
for (const label of ['套餐版本', '订阅历史', '权益版本', '用量事件', '成本事件']) {
assert.match(historyPanel, new RegExp(label, 'u'))
}
assert.match(historyPanel, /加载历史/u)
assert.match(historyPanel, /刷新历史/u)
assert.match(historyPanel, /这不是零用量或零成本/u)
assert.match(historyPanel, /manage-subscription/u)
assert.match(historyPanel, /变更状态/u)
assert.match(historyPanel, /恢复/u)
})
test('商业工作台满足键盘、触控、响应式和减少动画语义', () => {
assert.match(styles, /min-height: 44px/u)
assert.match(styles, /:focus-visible/u)
assert.match(styles, /@media \(max-width: 760px\)/u)
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/u)
assert.match(mutationDialog, /@keydown\.esc/u)
assert.match(mutationDialog, /aria-label="关闭对话框"/u)
assert.match(pricingPanel, /tabindex="-1"/u)
assert.match(pricingPanel, /:disabled="loading"/u)
})
test('商业工作台已接入分析看板且不会误加载财务看板', () => {
assert.match(overviewView, /CommercialWorkspace/u)
assert.match(overviewView, /activeDashboard === 'commercial'/u)
assert.match(overviewView, /currentUser: \{ type: Object/u)
assert.match(appShell, /:current-user="currentUser"/u)
assert.match(appShell, /'commercial'/u)
assert.match(topBarOverviewRange, /\{ label: '商业化管理', value: 'commercial' \}/u)
assert.match(overviewModel, /dashboard === 'commercial'/u)
assert.match(
overviewModel,
/activeDashboardKey\.value === 'commercial' \|\| activeDashboardKey\.value === 'value'/u
)
})
function readSource(relativeFile) {
return readFileSync(fileURLToPath(new URL(relativeFile, import.meta.url)), 'utf8')
}

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
activateCommercialEntitlement,
activateCommercialPlan,
activateCommercialSubscription,
buildCommercialAnalyticsSearch,
buildCommercialPricingScenario,
createCommercialPlan,
createCommercialSubscription,
fetchCommercialAccount,
fetchCommercialAnalytics,
fetchCommercialCostEvents,
fetchCommercialEntitlements,
fetchCommercialPlans,
fetchCommercialSubscriptions,
fetchCommercialUsageEvents,
normalizeCommercialPayload,
recordCommercialCost,
recordCommercialUsage,
serializeCommercialPayload,
transitionCommercialSubscription,
upsertCommercialEntitlement
} from '../src/services/commercial.js'
function installFetch(handler) {
const original = globalThis.fetch
globalThis.fetch = handler
return () => { globalThis.fetch = original }
}
function jsonResponse(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' }
})
}
test('商业接口递归转换 snake_case 与 camelCase 且保留数组和金额字符串', () => {
const normalized = normalizeCommercialPayload({
data_status: 'partial',
customer_roi: { required_inputs: ['收费'], ratios: [{ reporting_currency: 'CNY', ratio: '1.20' }] }
})
assert.equal(normalized.dataStatus, 'partial')
assert.equal(normalized.customerRoi.requiredInputs[0], '收费')
assert.equal(normalized.customerRoi.ratios[0].reportingCurrency, 'CNY')
assert.equal(normalized.customerRoi.ratios[0].ratio, '1.20')
assert.deepEqual(serializeCommercialPayload({
planCode: 'enterprise',
configJson: { billingMode: 'overage' },
requiredInputs: ['invoice']
}), {
plan_code: 'enterprise',
config_json: { billing_mode: 'overage' },
required_inputs: ['invoice']
})
})
test('普通账号只读取自身账户,平台管理员显式选择租户后才使用跨租户路径', async () => {
const urls = []
const restore = installFetch(async (url) => {
urls.push(String(url))
return jsonResponse({ tenant_id: 'tenant-a', data_status: 'unavailable', quotas: [] })
})
try {
await fetchCommercialAccount({ platformAdmin: false, tenantId: 'should-not-leak' })
await fetchCommercialAccount({ platformAdmin: true, tenantId: 'tenant/a', asOf: '2026-07-16T00:00:00Z' })
} finally {
restore()
}
assert.equal(urls[0], '/api/v1/commercial/account')
assert.match(urls[1], /\/commercial\/admin\/tenants\/tenant%2Fa\/account\?as_of=2026-07-16T00%3A00%3A00Z$/u)
})
test('商业分析时间参数显式序列化且不附加客户端汇率或合计字段', async () => {
const search = buildCommercialAnalyticsSearch({
start: '2026-04-01T00:00:00Z',
end: '2026-07-01T00:00:00Z',
asOf: '2026-07-01T00:00:00Z',
fxRate: '7.2'
})
assert.equal(search.get('as_of'), '2026-07-01T00:00:00Z')
assert.equal(search.has('fx_rate'), false)
let requestedUrl = ''
const restore = installFetch(async (url) => {
requestedUrl = String(url)
return jsonResponse({
tenant_id: 'tenant-a',
data_quality_status: 'unavailable',
customer_charges: {},
internal_costs: {},
contribution_margin: {},
verified_cash_savings: {},
customer_roi: {},
customer_labor_value: {}
})
})
try {
await fetchCommercialAnalytics('tenant-a', Object.fromEntries(search))
} finally {
restore()
}
assert.match(requestedUrl, /\/commercial\/admin\/tenants\/tenant-a\/analytics/u)
assert.match(requestedUrl, /start=2026-04-01T00%3A00%3A00Z/u)
})
test('定价场景使用平台租户 POST 契约且只提交服务端 schema 字段', async () => {
let request = null
const restore = installFetch(async (url, options = {}) => {
request = { url: String(url), method: options.method, body: JSON.parse(options.body) }
return jsonResponse({
tenant_id: 'tenant-a',
recommended_model: 'pilot_collecting',
evidence_status: 'unavailable',
scenarios: [],
notes: ['不会自动修改套餐']
})
})
try {
const response = await buildCommercialPricingScenario('tenant/a', {
start: '2026-04-01T00:00:00Z',
end: '2026-07-01T23:59:59Z',
asOf: '2026-07-02T00:00:00Z',
targetContributionMarginRate: '0.65',
maxVerifiedSavingsShare: '0.25'
})
assert.equal(response.recommendedModel, 'pilot_collecting')
} finally {
restore()
}
assert.equal(request.method, 'POST')
assert.match(request.url, /\/commercial\/admin\/tenants\/tenant%2Fa\/pricing-scenarios$/u)
assert.deepEqual(Object.keys(request.body).sort(), [
'as_of',
'end',
'max_verified_savings_share',
'start',
'target_contribution_margin_rate'
])
assert.equal(request.body.target_contribution_margin_rate, '0.65')
assert.equal(request.body.max_verified_savings_share, '0.25')
})
test('商业写接口覆盖套餐、订阅、权益、激活、用量与成本并统一递归序列化', async () => {
const requests = []
const restore = installFetch(async (url, options = {}) => {
requests.push({ url: String(url), method: options.method, headers: options.headers, body: JSON.parse(options.body) })
return jsonResponse({ id: 'resource-1', created: true })
})
try {
await createCommercialPlan('tenant-a', { planCode: 'enterprise', contractTermsJson: { approvedBy: 'cfo' } })
await activateCommercialPlan('tenant-a', 'plan-1', 2, '启用新套餐版本')
await createCommercialSubscription('tenant-a', { subscriptionKey: 'sub-1', autoRenew: true })
await activateCommercialSubscription('tenant-a', 'sub-1', 3, '恢复客户订阅')
await transitionCommercialSubscription('tenant-a', 'sub-1', { expectedVersion: 4, targetStatus: 'suspended', reason: '合同暂停' })
await upsertCommercialEntitlement('tenant-a', { entitlementKey: 'ocr', configJson: { billingMode: 'all_usage' } })
await activateCommercialEntitlement('tenant-a', 'ent-1', 4, '恢复 OCR 权益')
await recordCommercialUsage('tenant-a', { idempotencyKey: 'usage-1', subjectType: 'claim' })
await recordCommercialCost('tenant-a', { idempotencyKey: 'cost-1', originalCurrency: 'CNY' })
} finally {
restore()
}
assert.deepEqual(requests.map((item) => item.method), ['POST', 'POST', 'POST', 'POST', 'POST', 'PUT', 'POST', 'POST', 'POST'])
assert.match(requests[0].url, /\/plans$/u)
assert.equal(requests[0].body.contract_terms_json.approved_by, 'cfo')
assert.equal(requests[1].body.expected_version, 2)
assert.equal(requests[1].body.reason, '启用新套餐版本')
assert.ok(requests.every((item) => item.headers['X-Request-Id']))
assert.equal(requests[2].body.auto_renew, true)
assert.match(requests[4].url, /\/subscriptions\/sub-1\/transition$/u)
assert.equal(requests[4].body.target_status, 'suspended')
assert.equal(requests[5].body.config_json.billing_mode, 'all_usage')
assert.match(requests[7].url, /\/usage-events$/u)
assert.match(requests[8].url, /\/cost-events$/u)
})
test('五类商业历史按最终分资源契约读取并正确序列化筛选', async () => {
const urls = []
const restore = installFetch(async (url) => {
urls.push(String(url))
return jsonResponse([])
})
try {
await fetchCommercialPlans('tenant-a', { planStatus: 'active', limit: 20, offset: 1 })
await fetchCommercialSubscriptions('tenant-a', { subscriptionStatus: 'suspended' })
await fetchCommercialEntitlements('tenant-a', { subscriptionId: 'sub-1', entitlementStatus: 'active' })
await fetchCommercialUsageEvents('tenant-a', { subscriptionId: 'sub-1', start: '2026-07-01T00:00:00Z', end: '2026-08-01T00:00:00Z' })
await fetchCommercialCostEvents('tenant-a', { limit: 50 })
} finally {
restore()
}
assert.match(urls[0], /\/plans\?plan_status=active&limit=20&offset=1$/u)
assert.match(urls[1], /\/subscriptions\?subscription_status=suspended$/u)
assert.match(urls[2], /\/entitlements\?subscription_id=sub-1&entitlement_status=active$/u)
assert.match(urls[3], /\/usage-events\?subscription_id=sub-1&start=.*&end=.*/u)
assert.match(urls[4], /\/cost-events\?limit=50$/u)
})
test('后端权限和冲突错误保留状态与可执行提示', async () => {
const restore = installFetch(async () => jsonResponse({ detail: '只有平台管理员可以执行该操作。' }, 403))
try {
await assert.rejects(
() => fetchCommercialAnalytics('tenant-a'),
(error) => error.status === 403 && /平台管理员/u.test(error.message)
)
} finally {
restore()
}
})

View File

@@ -0,0 +1,269 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAnalyticsWindow,
buildCommercialMutation,
buildCommercialValueLedgers,
buildPricingScenarioPayload,
buildPricingScenarioView,
buildQuotaRows,
classifyCommercialAccountState,
createDefaultPricingScenarioForm,
formatCurrencyAmount,
formatMetricValues,
formatRatio
} from '../src/components/commercial/commercialWorkspaceModel.js'
test('客户价值账和平台经营账保持严格分离', () => {
const ledgers = buildCommercialValueLedgers({
verifiedCashSavings: metric('verified_cash_savings', '客户财务确认现金节省', [{ currency: 'CNY', amount: '100' }]),
customerCharges: metric('customer_charges', '客户合同收费', [{ currency: 'CNY', amount: '40' }], 'partial'),
customerRoi: { ...metric('customer_roi', '客户现金 ROI', [], 'partial'), ratios: [{ currency: 'CNY', ratio: '1.5', numerator: '60', denominator: '40' }] },
customerLaborValue: unavailable('customer_labor_value', '客户认可工时价值'),
internalCosts: metric('internal_costs', '平台内部成本', [{ currency: 'CNY', amount: '12' }]),
contributionMargin: metric('contribution_margin', '贡献毛利', [{ currency: 'CNY', amount: '28' }], 'partial')
})
assert.deepEqual(ledgers.customer.map((item) => item.category), [
'verifiedCashSavings', 'customerCharges', 'customerRoi', 'customerLaborValue'
])
assert.deepEqual(ledgers.platform.map((item) => item.category), ['internalCosts', 'contributionMargin'])
assert.equal(ledgers.customer[2].displayValue, '150.0%')
assert.equal(ledgers.platform[0].displayValue, formatCurrencyAmount('12', 'CNY'))
})
test('不同币种逐行保留,绝不在前端求和或换算', () => {
const values = formatMetricValues(metric('verified', '已核验', [
{ currency: 'CNY', amount: '100' },
{ currency: 'USD', amount: '20' }
]))
assert.deepEqual(values.map((item) => item.currency), ['CNY', 'USD'])
assert.deepEqual(values.map((item) => item.amount), [100, 20])
assert.match(values[0].displayValue, /100/u)
assert.match(values[1].displayValue, /20/u)
assert.equal(formatRatio('0.25'), '25.0%')
})
test('没有真实财务证据时明确不可用,不把未知伪装成零', () => {
const [cash, labor] = buildCommercialValueLedgers({
verifiedCashSavings: unavailable('verified_cash_savings', '客户财务确认现金节省'),
customerCharges: unavailable('customer_charges', '客户合同收费'),
customerRoi: unavailable('customer_roi', '客户现金 ROI'),
customerLaborValue: unavailable('customer_labor_value', '客户认可工时价值'),
internalCosts: unavailable('internal_costs', '平台内部成本'),
contributionMargin: unavailable('contribution_margin', '贡献毛利')
}).customer
assert.equal(cash.status, 'unavailable')
assert.equal(cash.displayValue, '不可用')
assert.deepEqual(cash.values, [])
assert.equal(labor.displayValue, '不可用')
assert.deepEqual(labor.requiredInputs, ['真实财务证据'])
})
test('账户状态完整区分加载、权限、错误、未配置、部分与就绪', () => {
assert.equal(classifyCommercialAccountState({ loading: true }), 'loading')
assert.equal(classifyCommercialAccountState({ error: { status: 403 } }), 'permission')
assert.equal(classifyCommercialAccountState({ error: new Error('offline') }), 'error')
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'unavailable' } }), 'unavailable')
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'partial' } }), 'partial')
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'available' } }), 'ready')
})
test('权益列表保持失败关闭语义并区分不限量和未知硬上限', () => {
const rows = buildQuotaRows({ quotas: [
{ status: 'unlimited', usedQuantity: '12', hardLimitRemaining: null, commerciallyAllowed: true, entitlement: { id: 'e1', entitlementKey: 'ocr', metricKey: 'ocr_calls', unit: '次', entitlementType: 'unlimited', version: 1 } },
{ status: 'inactive', usedQuantity: '0', hardLimitRemaining: null, commerciallyAllowed: false, reason: '订阅暂停', entitlement: { id: 'e2', entitlementKey: 'ai', metricKey: 'ai_calls', unit: '次', entitlementType: 'metered', version: 2 } }
] })
assert.equal(rows[0].remainingLabel, '不限量')
assert.equal(rows[1].remainingLabel, '未配置硬上限')
assert.equal(rows[1].commerciallyAllowed, false)
assert.equal(rows[1].reason, '订阅暂停')
})
test('分析窗口生成显式时区并拒绝倒序和超过两年', () => {
const window = buildAnalyticsWindow({ start: '2026-04-01', end: '2026-07-01' })
assert.match(window.start, /(Z|[+-]\d{2}:\d{2})$/u)
assert.match(window.end, /(Z|[+-]\d{2}:\d{2})$/u)
assert.throws(() => buildAnalyticsWindow({ start: '2026-07-01', end: '2026-04-01' }), /早于/u)
assert.throws(() => buildAnalyticsWindow({ start: '2023-01-01', end: '2026-07-01' }), /731/u)
})
test('定价场景将百分比和时间窗转换为后端精确契约并执行前置校验', () => {
const defaults = createDefaultPricingScenarioForm(
{ start: '2026-04-01', end: '2026-07-01' },
new Date('2026-07-02T08:30:00Z')
)
assert.equal(defaults.targetContributionMarginPercent, '65')
assert.equal(defaults.maxVerifiedSavingsSharePercent, '25')
const payload = buildPricingScenarioPayload({
...defaults,
asOf: '2026-07-02T08:30:00Z',
targetContributionMarginPercent: '65',
maxVerifiedSavingsSharePercent: '25'
})
assert.match(payload.start, /(Z|[+-]\d{2}:\d{2})$/u)
assert.match(payload.end, /(Z|[+-]\d{2}:\d{2})$/u)
assert.equal(payload.asOf, '2026-07-02T08:30:00.000Z')
assert.equal(payload.targetContributionMarginRate, '0.65')
assert.equal(payload.maxVerifiedSavingsShare, '0.25')
assert.throws(() => buildPricingScenarioPayload({
...defaults, targetContributionMarginPercent: '95'
}), /小于 95/u)
assert.throws(() => buildPricingScenarioPayload({
...defaults, targetContributionMarginPercent: '94.999999'
}), /小于 95/u)
assert.equal(buildPricingScenarioPayload({
...defaults, targetContributionMarginPercent: '94.9999'
}).targetContributionMarginRate, '0.949999')
assert.throws(() => buildPricingScenarioPayload({
...defaults, maxVerifiedSavingsSharePercent: '0'
}), /大于 0/u)
assert.throws(() => buildPricingScenarioPayload({
...defaults, asOf: '2026-03-01T00:00:00Z'
}), /不能早于/u)
})
test('定价结果逐币种展示价格走廊且绝不把缺失事实显示为零', () => {
const view = buildPricingScenarioView({
tenantId: 'tenant-a',
start: '2026-04-01T00:00:00Z',
end: '2026-07-01T23:59:59Z',
asOf: '2026-07-02T00:00:00Z',
targetContributionMarginRate: '0.65',
maxVerifiedSavingsShare: '0.25',
recommendedModel: 'hybrid',
evidenceStatus: 'partial',
scenarios: [
{
currency: 'CNY', status: 'feasible', internalCost: '20', verifiedCashSavings: '200',
minimumSustainableCharge: '57.1429', maximumValueAlignedCharge: '50',
maximumSuccessFee: '0', customerRoiAtMinimumCharge: '2.5',
contributionMarginAtValueCeiling: '0.6', reason: '独立 CNY 走廊'
},
{
currency: 'USD', status: 'cost_only', internalCost: '5', verifiedCashSavings: null,
minimumSustainableCharge: '14.2857', maximumValueAlignedCharge: null,
maximumSuccessFee: null, customerRoiAtMinimumCharge: null,
contributionMarginAtValueCeiling: null, reason: '缺少 USD 价值证据'
}
],
notes: ['结果不会自动写套餐']
})
assert.equal(view.recommendedModelLabel, '混合定价(基础订阅 + 封顶成功费)')
assert.equal(view.multiCurrency, true)
assert.deepEqual(view.rows.map((row) => row.currency), ['CNY', 'USD'])
assert.match(view.rows[0].minimumSustainableChargeLabel, /57/u)
assert.equal(view.rows[1].maximumValueAlignedChargeLabel, '不可用')
assert.equal(view.rows[1].maximumSuccessFeeLabel, '不可用')
assert.equal(view.rows[1].customerRoiLabel, '—')
assert.deepEqual(view.rows[1].missingInputs, ['同币种财务确认现金节省'])
assert.doesNotMatch(view.rows[1].maximumValueAlignedChargeLabel, /0/u)
})
test('完全无证据的定价响应保留 unavailable 与缺失说明', () => {
const view = buildPricingScenarioView({
recommendedModel: 'pilot_collecting', evidenceStatus: 'unavailable', scenarios: [], notes: []
})
assert.equal(view.evidenceStatus, 'unavailable')
assert.equal(view.evidenceStatusLabel, '不可用')
assert.equal(view.recommendedModelLabel, '试点采集证据')
assert.deepEqual(view.rows, [])
})
test('套餐、订阅与权益表单构造服务端事实时间和版本化载荷', () => {
const plan = buildCommercialMutation('createPlan', {
planCode: 'enterprise', name: '企业版', pricingModel: 'hybrid', billingInterval: 'annual',
currency: 'cny', baseFee: '1000', includedSeats: 20, overageEnabled: true,
effectiveFrom: '2026-07-16T09:30', effectiveTo: '', contractTermsJson: '{"approved_by":"cfo"}'
})
assert.equal(plan.currency, 'CNY')
assert.match(plan.effectiveFrom, /Z$/u)
assert.equal(plan.contractTermsJson.approved_by, 'cfo')
const subscription = buildCommercialMutation('createSubscription', {
subscriptionKey: 'sub-enterprise', status: 'active', startsAt: '2026-07-16T09:30',
currentPeriodStart: '2026-07-16T09:30', currentPeriodEnd: '2026-08-16T09:30',
seats: 20, metadataJson: '{}'
}, { plan: { id: 'plan-1' } })
assert.equal(subscription.planId, 'plan-1')
assert.match(subscription.currentPeriodEnd, /Z$/u)
const entitlement = buildCommercialMutation('upsertEntitlement', {
entitlementKey: 'ocr', metricKey: 'ocr_calls', entitlementType: 'metered', unit: '次',
includedQuantity: '100', hardLimitQuantity: '120', resetInterval: 'monthly',
overagePolicy: 'block', effectiveFrom: '2026-07-16T09:30', configJson: '{}'
}, { subscription: { id: 'sub-1' } })
assert.equal(entitlement.subscriptionId, 'sub-1')
assert.equal(entitlement.hardLimitQuantity, '120')
})
test('客户端在写入前拒绝不完整窗口、降配和无效 JSON', () => {
assert.throws(() => buildCommercialMutation('createPlan', {
planCode: 'p', name: 'P', pricingModel: 'subscription', billingInterval: 'annual', currency: 'CNY',
baseFee: '0', includedSeats: 1, effectiveFrom: '2026-08-01T00:00', effectiveTo: '2026-07-01T00:00', contractTermsJson: '{}'
}), /晚于/u)
assert.throws(() => buildCommercialMutation('upsertEntitlement', {
entitlementKey: 'ocr', metricKey: 'ocr', entitlementType: 'metered', unit: '次', includedQuantity: '100',
hardLimitQuantity: '10', resetInterval: 'monthly', overagePolicy: 'block', effectiveFrom: '2026-07-01T00:00', configJson: '{}'
}, { subscription: { id: 'sub-1' } }), /不能小于/u)
assert.throws(() => buildCommercialMutation('createPlan', {
planCode: 'p', name: 'P', pricingModel: 'subscription', billingInterval: 'annual', currency: 'CNY',
baseFee: '0', includedSeats: 1, effectiveFrom: '2026-07-01T00:00', contractTermsJson: '{bad}'
}), /有效 JSON/u)
})
test('用量和成本事件在客户端执行幂等、符号、冲回与币种边界校验', () => {
const account = { subscription: { id: 'sub-1' } }
const usage = buildCommercialMutation('recordUsage', {
entitlementId: 'ent-1', eventType: 'usage', quantity: '1', occurredAt: '2026-07-16T09:30',
sourceSystem: 'web', idempotencyKey: 'usage-1', metadataJson: '{}'
}, account)
assert.equal(usage.subscriptionId, 'sub-1')
assert.match(usage.occurredAt, /Z$/u)
assert.throws(() => buildCommercialMutation('recordUsage', {
entitlementId: 'ent-1', eventType: 'credit', quantity: '1', occurredAt: '2026-07-16T09:30',
sourceSystem: 'web', idempotencyKey: 'usage-2', metadataJson: '{}'
}, account), /必须小于 0/u)
assert.throws(() => buildCommercialMutation('recordUsage', {
entitlementId: 'ent-1', eventType: 'reversal', quantity: '-1', occurredAt: '2026-07-16T09:30',
sourceSystem: 'web', idempotencyKey: 'usage-3', metadataJson: '{}'
}, account), /被冲回/u)
const cost = buildCommercialMutation('recordCost', {
eventType: 'incurred', costCategory: 'ai_inference', quantity: '3', unit: '次', unitCost: '0.02',
originalCurrency: 'usd', reportingCurrency: 'cny', fxRate: '7.2', allocationKey: 'ai-july',
occurredAt: '2026-07-16T09:30', sourceSystem: 'billing', idempotencyKey: 'cost-1', metadataJson: '{}'
}, account)
assert.equal(cost.originalCurrency, 'USD')
assert.equal(cost.reportingCurrency, 'CNY')
assert.equal(cost.fxRate, '7.2')
})
test('订阅状态变更必须使用版本、受限目标状态和可审计原因', () => {
const transition = buildCommercialMutation('transitionSubscription', {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'suspended', reason: '客户合同暂停'
})
assert.deepEqual(transition, {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'suspended', reason: '客户合同暂停'
})
assert.throws(() => buildCommercialMutation('transitionSubscription', {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'active', reason: '恢复'
}), /目标订阅状态无效/u)
assert.throws(() => buildCommercialMutation('transitionSubscription', {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'canceled', reason: 'x'
}), /至少填写 2/u)
})
function metric(key, label, values, status = 'available') {
return { key, label, status, values, ratios: [], reason: '真实事实口径', requiredInputs: [], notes: [] }
}
function unavailable(key, label) {
return { key, label, status: 'unavailable', values: [], ratios: [], reason: '缺少真实财务证据', requiredInputs: ['真实财务证据'], notes: [] }
}

View File

@@ -9,6 +9,10 @@ const topBar = readFileSync(
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
'utf8'
)
const topBarOverviewRange = readFileSync(
fileURLToPath(new URL('../src/components/layout/useTopBarOverviewRange.js', import.meta.url)),
'utf8'
)
const overviewView = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
@@ -62,7 +66,10 @@ test('digital employee dashboard normalizes backend payload fields', () => {
})
test('digital employee dashboard is wired into overview dashboard switch', () => {
assert.match(topBar, /label: '数字员工看板', value: 'digitalEmployee'/)
assert.match(topBar, /import \{ useTopBarOverviewRange \} from '\.\/useTopBarOverviewRange\.js'/)
assert.match(topBar, /:options="overviewDashboardOptions"/)
assert.match(topBar, /useTopBarOverviewRange\(props, emit\)/)
assert.match(topBarOverviewRange, /label: '数字员工看板', value: 'digitalEmployee'/)
assert.match(overviewView, /<DigitalEmployeeDashboard/)
assert.match(overviewView, /activeDashboard === 'digitalEmployee'/)
assert.match(overviewView, /digitalEmployeeKpiMetrics/)

View File

@@ -149,10 +149,10 @@ const flowScript = [
'../src/views/scripts/travelReimbursementFlowToolModel.js',
'../src/views/scripts/travelReimbursementFlowTiming.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const personalWorkbenchAiModeScript = [
'../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js',
'../src/composables/workbenchAiMode/useWorkbenchAiIntentExecution.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const applicationPreviewFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
'utf8'
@@ -276,7 +276,7 @@ test('AI workbench routes compact travel direct-submit planner into preview with
)
assert.match(
personalWorkbenchAiModeScript,
/const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/
/resolveExecutableTravelApplicationPlan\(\s*buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)\s*\)/
)
assert.match(
personalWorkbenchAiModeScript,

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
import {
fetchFinancialConnectorObservability,
fetchFinancialPaymentEvidence
} from '../src/services/financialConnectors.js'
const panelPath = fileURLToPath(new URL('../src/components/dashboard/FinancialConnectorHealthPanel.vue', import.meta.url))
const panelSource = readFileSync(panelPath, 'utf8')
const overviewSource = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
)
test('连接器观测与付款证据接口使用租户内路径并限制窗口', async () => {
const requests = []
const originalFetch = globalThis.fetch
globalThis.fetch = async (url) => {
requests.push(String(url))
return new Response(JSON.stringify({ summary: {}, items: [] }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
try {
await fetchFinancialConnectorObservability({ windowHours: 999 })
await fetchFinancialPaymentEvidence('claim/a')
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requests[0], '/api/v1/financial-connectors/observability?window_hours=720')
assert.equal(requests[1], '/api/v1/financial-connectors/payment-evidence/claim%2Fa')
await assert.rejects(() => fetchFinancialPaymentEvidence(''), /不能为空/u)
})
test('连接器健康面板可以独立编译并接入财务看板', () => {
const parsed = parse(panelSource, { filename: panelPath })
assert.deepEqual(parsed.errors, [])
const script = compileScript(parsed.descriptor, { id: 'financial-connector-health-panel' })
const template = compileTemplate({
source: parsed.descriptor.template?.content || '',
filename: panelPath,
id: 'financial-connector-health-panel',
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
assert.match(overviewSource, /<FinancialConnectorHealthPanel/u)
assert.match(overviewSource, /activeDashboard === 'finance'/u)
})
test('面板明确区分证据等级且不把未采集运行指标伪装成零', () => {
assert.match(panelSource, /生产外部回执 · 高可信现金事实/u)
assert.match(panelSource, /人工付款确认 · 低等级内部状态/u)
assert.match(panelSource, /模拟\/预发布回执 · 仅验证,不入核心账/u)
assert.match(panelSource, /retry_count == null \? '待耐久遥测'/u)
assert.match(panelSource, /signature_failure_count == null \? '待耐久遥测'/u)
assert.doesNotMatch(panelSource, /raw_payload|signature_header|secret_ref/u)
})

View File

@@ -13,7 +13,7 @@ function testIngestTimeUsesDedicatedColumn() {
)
assert.match(
policiesView,
/<td>\s*<div class="state-cell">\s*<span class="state-tag"[^>]*>\{\{ doc\.state \}\}<\/span>\s*<\/div>\s*<\/td>\s*<td class="ingest-time-cell">\{\{ doc\.ingestTime \|\| '—' \}\}<\/td>/,
/<td data-label="状态">\s*<div class="state-cell">\s*<span class="state-tag"[^>]*>\{\{ doc\.state \}\}<\/span>\s*<\/div>\s*<\/td>\s*<td class="ingest-time-cell" data-label="归纳时间">\{\{ doc\.ingestTime \|\| '—' \}\}<\/td>/,
'状态列只展示状态标签,归纳时间需要放到独立单元格'
)
assert.doesNotMatch(

View File

@@ -10,6 +10,7 @@ function readProjectFile(path) {
function testReceiptFolderViewSurface() {
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
const formatting = readProjectFile('web/src/views/scripts/receiptFolderFormatting.js')
assert.match(view, /activeStatus = ref\('all'\)/)
assert.match(view, /value: 'all'/)
@@ -32,7 +33,9 @@ function testReceiptFolderViewSurface() {
assert.match(view, /receiptEditLogs/)
assert.match(view, /previewFrameUrl/)
assert.match(view, /previewTransform/)
assert.match(view, /String\(value \?\? ''\)\.trim\(\)/)
assert.match(view, /formatReceiptDateTime as formatDateTime/)
assert.match(view, /formatReceiptRecognitionScore as formatScore/)
assert.match(formatting, /String\(value \?\? ''\)\.trim\(\)/)
assert.match(view, /openAssociateDialogForCurrentReceipt/)
assert.match(view, /createReceiptDetailDashboardModel/)
assert.match(view, /createReceiptDetailFieldModel/)

View File

@@ -40,6 +40,10 @@ const topBarComponent = readFileSync(
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
'utf8'
)
const topBarOverviewRange = readFileSync(
fileURLToPath(new URL('../src/components/layout/useTopBarOverviewRange.js', import.meta.url)),
'utf8'
)
const appShellComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
@@ -144,10 +148,13 @@ test('risk dashboard follows the top overview range without card-level selectors
test('overview custom date defaults use current year instead of hard-coded legacy dates', () => {
assert.match(topBarComponent, /createCurrentYearDateRange/)
assert.match(topBarComponent, /formatDateValue/)
assert.match(topBarComponent, /useTopBarOverviewRange/)
assert.match(topBarOverviewRange, /formatDateValue/)
assert.match(topBarOverviewRange, /useTopBarOverviewRange\(props, emit\)/)
assert.match(appShellComposable, /createCurrentYearDateRange\(\)/)
assert.match(legacyAppScript, /createCurrentYearDateRange\(\)/)
assert.doesNotMatch(topBarComponent, /2024-07-06|2024-07-12/)
assert.doesNotMatch(topBarOverviewRange, /2024-07-06|2024-07-12/)
assert.doesNotMatch(appShellComposable, /2024-07-06|2024-07-12/)
assert.doesNotMatch(legacyAppScript, /2024-07-06|2024-07-12/)
})

View File

@@ -7,10 +7,23 @@ const detailTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
const detailEntryScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const detailSetupScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSetup.js', import.meta.url)),
'utf8'
)
const detailApprovalFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailApprovalFlow.js', import.meta.url)),
'utf8'
)
const detailRiskSubmitScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailRiskSubmit.js', import.meta.url)),
'utf8'
)
const detailScript = [detailSetupScript, detailApprovalFlowScript, detailRiskSubmitScript].join('\n')
const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
@@ -60,7 +73,12 @@ function extractFunction(source, name) {
}
test('approval-mode detail collects leader opinion inside confirm dialog before API call', () => {
assert.match(detailScript, /approvalMode:/)
assert.match(detailEntryScript, /import \{ useTravelRequestDetailSetup \} from '\.\/travelRequestDetailSetup\.js'/)
assert.match(detailEntryScript, /setup: useTravelRequestDetailSetup/)
assert.match(detailSetupScript, /import \{ useTravelRequestDetailApprovalFlow \} from '\.\/useTravelRequestDetailApprovalFlow\.js'/)
assert.match(detailSetupScript, /useTravelRequestDetailApprovalFlow\(\{/)
assert.match(detailSetupScript, /approvalRiskConfirmItems: riskSubmit\.approvalRiskConfirmItems/)
assert.match(detailEntryScript, /approvalMode:/)
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const approvalRiskConfirmed = ref\(false\)/)

View File

@@ -15,6 +15,10 @@ const responsiveStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view-part2.css', import.meta.url)),
'utf8'
)
const applicationFactsStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-request-application-facts.css', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
@@ -25,8 +29,8 @@ test('detail hero facts keep document number and date on one row on laptop scree
assert.match(detailStyles, /\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(240px,\s*1\.25fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(280px,\s*1\.4fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact strong \{[\s\S]*white-space:\s*nowrap/)
assert.match(detailStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
assert.match(detailStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
assert.match(applicationFactsStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
assert.match(applicationFactsStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
assert.doesNotMatch(detailScript, /key:\s*'status'[\s\S]*label:\s*'当前状态'/)
})

View File

@@ -127,6 +127,14 @@ const relatedApplicationCardTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestRelatedApplicationCard.vue', import.meta.url)),
'utf8'
)
const applicationFactsTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestApplicationFacts.vue', import.meta.url)),
'utf8'
)
const applicationFactsStyle = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-request-application-facts.css', import.meta.url)),
'utf8'
)
const progressCardTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
'utf8'
@@ -1583,10 +1591,12 @@ test('application detail uses application labels instead of reimbursement labels
assert.match(progressCardTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
assert.match(detailViewTemplate, /class="application-detail-facts"/)
assert.match(detailViewTemplate, /applicationDetailFactItems/)
assert.match(detailViewTemplate, /<TravelRequestApplicationFacts/)
assert.match(detailViewTemplate, /:fact-items="applicationDetailFactItems"/)
assert.match(applicationFactsTemplate, /class="application-detail-facts"/)
assert.match(applicationFactsTemplate, /v-for="item in factItems"/)
assert.match(detailViewScript, /buildApplicationDetailFactItems/)
assert.match(detailViewStyle, /\.application-detail-fact\.highlight strong/)
assert.match(applicationFactsStyle, /\.application-detail-fact\.highlight strong/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
@@ -1595,10 +1605,13 @@ test('application detail uses application labels instead of reimbursement labels
test('draft or returned application detail edits allowed facts inline', () => {
assert.doesNotMatch(detailViewTemplate, /修改申请/)
assert.match(detailViewTemplate, /canEditApplicationDetailItem\(item\)/)
assert.match(detailViewTemplate, /application-detail-edit-btn/)
assert.match(detailViewTemplate, /openApplicationDetailEditor\(item\)/)
assert.match(detailViewTemplate, /saveApplicationDetailEdit\(item\)/)
assert.match(detailViewTemplate, /:can-edit-item="canEditApplicationDetailItem"/)
assert.match(detailViewTemplate, /@open="openApplicationDetailEditor"/)
assert.match(detailViewTemplate, /@save="saveApplicationDetailEdit"/)
assert.match(applicationFactsTemplate, /canEditItem\(item\)/)
assert.match(applicationFactsTemplate, /application-detail-edit-btn/)
assert.match(applicationFactsTemplate, /emit\('open', item\)/)
assert.match(applicationFactsTemplate, /emit\('save', item\)/)
assert.doesNotMatch(detailViewScript, /handleModifyApplication/)
assert.match(
detailViewScript,

View File

@@ -16,7 +16,10 @@ const composerComponent = readSource('../src/components/business/workbench-ai/Wo
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const filePreviewComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFilePreviewDialog.vue')
const filePreviewStyles = readSource('../src/assets/styles/components/workbench-ai-file-preview-dialog.css')
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
const aiModeRuntime = [
readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
readSource('../src/composables/workbenchAiMode/useWorkbenchAiConversationRuntime.js')
].join('\n')
const filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js')
function countOccurrences(source, pattern) {

View File

@@ -20,10 +20,10 @@ import {
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import { createWorkbenchAiMessageRuntime } from '../src/composables/workbenchAiMode/workbenchAiMessageModel.js'
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const personalWorkbenchAiModeScript = [
'../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js',
'../src/composables/workbenchAiMode/useWorkbenchAiIntentExecution.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const stewardFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js', import.meta.url)),
'utf8'

View File

@@ -1,15 +1,28 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { buildExpenseSceneSelectionActions } from '../src/utils/expenseAssistantActions.js'
import { buildExpenseSceneSelectionMessage } from '../src/views/scripts/travelReimbursementConversationModel.js'
import { normalizeInlineApplicationStatusLabel } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
const aiMode = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
'utf8'
)
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
const applicationPreviewFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js')
const applicationPreviewModel = readSource('../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js')
const aiMode = [
readSource('../src/components/business/PersonalWorkbenchAiMode.vue'),
readSource('../src/components/business/PersonalWorkbenchAiMode.template.html'),
readSource('../src/utils/aiDocumentQueryModel.js'),
...readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js'))
.sort()
.map((file) => readSource(`../src/composables/workbenchAiMode/${file}`))
].join('\n')
test('expense scene selection message asks for type first and mentions application gate', () => {
const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。')
@@ -32,10 +45,10 @@ test('expense scene actions mark travel and meal as requiring application', () =
assert.equal(transport.payload.next_session_type, 'expense')
})
test('AI mode quick reimbursement card opens scene selection before steward plan', () => {
test('AI mode quick reimbursement card enters the reimbursement association gate before steward plan', () => {
assert.match(
aiMode,
/function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/
/function runAiModeAction\(item\) {[\s\S]{0,300}expenseFlow\.startAiReimbursementAssociationGate\(item\.prompt, item\.label\)/
)
})
@@ -53,27 +66,28 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
assert.match(aiMode, /applicationPreview:\s*preview/)
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/)
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(registeredPreview\)/)
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
})
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
test('AI mode steward reimbursement action enters the association gate while standalone flow keeps scene selection', () => {
assert.match(aiMode, /buildExpenseSceneSelectionMessage/)
assert.match(aiMode, /buildExpenseSceneSelectionActions/)
assert.match(aiMode, /SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /function pushInlineExpenseSceneSelectionPrompt/)
assert.match(aiMode, /payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)/)
assert.match(aiMode, /action\?\.payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)/)
assert.match(
aiMode,
/SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/
/SESSION_TYPE_EXPENSE && carryText === '我要报销'[\s\S]{0,160}expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)[\s\S]{0,40}return/
)
assert.match(aiMode, /SKIP_REQUIRED_APPLICATION_LINK_ACTION[\s\S]{0,180}pushInlineExpenseSceneSelectionPrompt/)
})
test('AI mode attaches required application lookup result before steward planning', () => {
assert.match(aiMode, /async function attachAiRequiredApplicationGate\(planRequest, prompt\)/)
assert.match(aiMode, /fetchExpenseClaims\(\)/)
assert.match(aiMode, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/)
assert.match(aiMode, /required_application_gate/)
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
@@ -95,7 +109,8 @@ test('AI mode handles document query prompts locally before steward planning', (
assert.match(aiMode, /查询业务单据接口/)
assert.match(aiMode, /组合筛选单据/)
assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable\(detailRequest\)/)
assert.match(aiMode, /emit\('open-document', detailRequest\)/)
})
test('AI mode asks for manual confirmation before generating application preview table', () => {
@@ -114,8 +129,8 @@ test('AI mode asks for manual confirmation before generating application preview
})
test('AI mode shows pending feedback before async application preview estimate refresh', () => {
const startPreviewFunction = aiMode.match(
/async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/
const startPreviewFunction = applicationPreviewFlow.match(
/async function startAiApplicationPreview[\s\S]*?\n }\n\n return \{/
)?.[0] || ''
assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/)
@@ -142,8 +157,8 @@ test('AI mode handles application preview save and submit through buttons or tex
assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/)
assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/)
assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
assert.match(aiMode, /if \(applicationFlow\.handleInlineApplicationPreviewTextAction\(cleanPrompt, applicationPreviewEstimatePending\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/)
assert.match(aiMode, /normalizedPreview\.readyToSubmit/)
assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
@@ -155,22 +170,23 @@ test('AI mode handles application preview save and submit through buttons or tex
test('AI mode keeps missing application fields editable in the preview table without quick template action', () => {
assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/)
assert.match(aiMode, /label:\s*'保存草稿'/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
assert.doesNotMatch(aiMode, /label:\s*'快速模板'/)
assert.doesNotMatch(aiMode, /action_type:\s*'prefill_composer'/)
assert.doesNotMatch(aiMode, /buildInlineApplicationPreviewTemplatePrefill/)
assert.doesNotMatch(aiMode, /applyInlineApplicationPreviewTemplateText/)
})
test('AI mode waits for submit confirmation before adding submit action to the conversation', () => {
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)')
test('AI mode confirms a new direct submit before adding the action while allowing contextual saved-draft submit', () => {
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly)')
const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex)
const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex)
assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支')
assert.match(executeBlock, /const shouldSubmitSavedDraftDirectly = isSubmit/)
assert.ok(confirmGateIndex >= 0, '非上下文草稿提交应先进入确认分支')
assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗')
assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息')
assert.match(
@@ -178,22 +194,22 @@ test('AI mode waits for submit confirmation before adding submit action to the c
/requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/
)
const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()')
const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart)
const confirmBlock = aiMode.slice(confirmStart, confirmEnd)
const confirmStart = applicationPreviewFlow.indexOf('function confirmInlineApplicationSubmit()')
const confirmEnd = applicationPreviewFlow.indexOf('\n async function runInlineApplicationSubmitPrecheck', confirmStart)
const confirmBlock = applicationPreviewFlow.slice(confirmStart, confirmEnd)
assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/)
assert.match(confirmBlock, /skipUserMessage:\s*false/)
const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()')
const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart)
const cancelBlock = aiMode.slice(cancelStart, cancelEnd)
const cancelStart = applicationPreviewFlow.indexOf('function cancelInlineApplicationSubmitConfirm()')
const cancelEnd = applicationPreviewFlow.indexOf('\n function confirmInlineApplicationSubmit', cancelStart)
const cancelBlock = applicationPreviewFlow.slice(cancelStart, cancelEnd)
assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/)
})
test('AI mode formats saved application draft as a detail table without continuing submit flow', () => {
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
assert.match(aiMode, /submitted:\s*'审批中'/)
assert.equal(normalizeInlineApplicationStatusLabel('submitted'), '审批中')
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
@@ -204,9 +220,9 @@ test('AI mode formats saved application draft as a detail table without continui
assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
assert.match(aiMode, /action_type:\s*'open_application_detail'/)
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
const resultBlock = aiMode.slice(resultStart, resultEnd)
const resultStart = applicationPreviewModel.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = applicationPreviewModel.indexOf('\nexport function buildInlineApplicationDetailAction', resultStart)
const resultBlock = applicationPreviewModel.slice(resultStart, resultEnd)
const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT')
const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'")
const saveBranch = resultBlock.slice(saveBranchIndex)
@@ -219,15 +235,16 @@ test('AI mode formats saved application draft as a detail table without continui
)
assert.doesNotMatch(saveBranch, /进入审批流程/)
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/)
assert.doesNotMatch(
executeBlock,
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
)
assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
assert.match(executeBlock, /const detailActions = buildInlineApplicationDetailAction\(draftPayload\)/)
assert.match(executeBlock, /suggestedActions:\s*shouldAutoContinueNextTask[\s\S]*detailActions/)
})
test('AI mode locks application preview actions while estimate refresh is pending', () => {
@@ -236,13 +253,13 @@ test('AI mode locks application preview actions while estimate refresh is pendin
aiMode,
/function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/
)
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/)
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
assert.match(aiMode, /:disabled="isAiModeInputLocked"/)
assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/)
assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/)
assert.match(
aiMode,
/message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/
/message\.suggestedActions = \[\][\s\S]*const committed = await commitBaseApplicationPreviewEditor\(message\)/
)
assert.match(
aiMode,

View File

@@ -28,128 +28,130 @@ function countGifFrameBlocks(buffer) {
return count
}
function readAssetFrames(assetPath, options = {}) {
const metadata = execFileSync('identify', ['-format', '%w %h %n\n', assetPath], {
encoding: 'utf8'
}).trim().split('\n')[0].split(/\s+/).map(Number)
const sourceWidth = metadata[0]
const sourceHeight = metadata[1]
const frameCount = metadata[2] || 1
const width = options.width || sourceWidth
const height = options.height || sourceHeight
const convertArgs = [assetPath, '-coalesce']
if (options.width || options.height) {
convertArgs.push('-resize', `${width}x${height}!`)
}
convertArgs.push('-alpha', 'off', '-depth', '8', 'rgb:-')
const pixels = execFileSync('convert', convertArgs, {
maxBuffer: 64 * 1024 * 1024
})
const frameSize = width * height * 3
return {
frameCount: Math.min(frameCount, Math.floor(pixels.length / frameSize)),
frameSize,
height,
pixels,
width
}
}
function measureGifMotion(assetPath) {
const script = `
from PIL import Image, ImageSequence
import json
import sys
image = Image.open(sys.argv[1])
frames = [frame.convert("RGB").resize((64, 64)) for frame in ImageSequence.Iterator(image)]
def delta(left, right):
left_pixels = left.load()
right_pixels = right.load()
total = 0
for y in range(64):
for x in range(64):
a = left_pixels[x, y]
b = right_pixels[x, y]
total += abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])
return total / (64 * 64 * 3)
adjacent = [delta(frames[index], frames[index + 1]) for index in range(len(frames) - 1)]
adjacent_sorted = sorted(adjacent)
median = adjacent_sorted[len(adjacent_sorted) // 2]
print(json.dumps({
"medianAdjacentDelta": median,
"seamDelta": delta(frames[-1], frames[0])
}))
`
return JSON.parse(execFileSync('python3', ['-', assetPath], {
encoding: 'utf8',
input: script
}).trim())
const { frameCount, frameSize, pixels } = readAssetFrames(assetPath, { width: 64, height: 64 })
const delta = (leftFrame, rightFrame) => {
const leftOffset = leftFrame * frameSize
const rightOffset = rightFrame * frameSize
let total = 0
for (let index = 0; index < frameSize; index += 1) {
total += Math.abs(pixels[leftOffset + index] - pixels[rightOffset + index])
}
return total / frameSize
}
const adjacent = Array.from(
{ length: Math.max(0, frameCount - 1) },
(_, index) => delta(index, index + 1)
).sort((left, right) => left - right)
return {
medianAdjacentDelta: adjacent[Math.floor(adjacent.length / 2)] || 0,
seamDelta: delta(frameCount - 1, 0)
}
}
function measureGifDuration(assetPath) {
const script = `
from PIL import Image
import sys
image = Image.open(sys.argv[1])
total = 0
for index in range(getattr(image, "n_frames", 1)):
image.seek(index)
total += image.info.get("duration", 0)
print(total)
`
return Number(execFileSync('python3', ['-', assetPath], {
encoding: 'utf8',
input: script
}).trim())
const delays = execFileSync('identify', ['-format', '%T\n', assetPath], {
encoding: 'utf8'
}).trim().split('\n').map(Number)
return delays.reduce((total, delay) => total + delay * 10, 0)
}
function measureOrbAssetPresentation(assetPath) {
const script = `
from PIL import Image
import json
import sys
const { frameCount, frameSize, height, pixels, width } = readAssetFrames(assetPath)
let minimumCornerLuma = 255
let maximumCornerLuma = 0
let minimumBackgroundSimilarityRatio = 1
let minimumForegroundWidthRatio = 1
let minimumForegroundHeightRatio = 1
image = Image.open(sys.argv[1])
frame_count = getattr(image, "n_frames", 1)
width, height = image.size
minimum_corner_luma = 255
maximum_corner_luma = 0
minimum_background_similarity_ratio = 1
minimum_foreground_width_ratio = 1
minimum_foreground_height_ratio = 1
for index in range(frame_count):
if frame_count > 1:
image.seek(index)
rgb = image.convert("RGB")
corners = [
rgb.getpixel((0, 0)),
rgb.getpixel((width - 1, 0)),
rgb.getpixel((0, height - 1)),
rgb.getpixel((width - 1, height - 1)),
for (let frame = 0; frame < frameCount; frame += 1) {
const frameOffset = frame * frameSize
const pixelAt = (x, y) => {
const offset = frameOffset + (y * width + x) * 3
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]]
}
const corners = [
pixelAt(0, 0),
pixelAt(width - 1, 0),
pixelAt(0, height - 1),
pixelAt(width - 1, height - 1)
]
corner_lumas = [sum(pixel) / 3 for pixel in corners]
minimum_corner_luma = min(minimum_corner_luma, min(corner_lumas))
maximum_corner_luma = max(maximum_corner_luma, max(corner_lumas))
background = tuple(round(sum(pixel[channel] for pixel in corners) / len(corners)) for channel in range(3))
foreground_mask = Image.new("L", (width, height), 0)
foreground_pixels = foreground_mask.load()
background_similarity = 0
rgb_pixels = rgb.load()
for y in range(height):
for x in range(width):
pixel = rgb_pixels[x, y]
diff = sum(abs(pixel[channel] - background[channel]) for channel in range(3))
if diff > 22:
foreground_pixels[x, y] = 255
if diff <= 12:
background_similarity += 1
foreground_box = foreground_mask.getbbox()
if foreground_box:
minimum_foreground_width_ratio = min(
minimum_foreground_width_ratio,
(foreground_box[2] - foreground_box[0]) / width
)
minimum_foreground_height_ratio = min(
minimum_foreground_height_ratio,
(foreground_box[3] - foreground_box[1]) / height
)
minimum_background_similarity_ratio = min(
minimum_background_similarity_ratio,
background_similarity / (width * height)
)
const cornerLumas = corners.map((pixel) => (pixel[0] + pixel[1] + pixel[2]) / 3)
minimumCornerLuma = Math.min(minimumCornerLuma, ...cornerLumas)
maximumCornerLuma = Math.max(maximumCornerLuma, ...cornerLumas)
const background = [0, 1, 2].map((channel) => Math.round(
corners.reduce((total, pixel) => total + pixel[channel], 0) / corners.length
))
let backgroundSimilarity = 0
let minX = width
let minY = height
let maxX = -1
let maxY = -1
print(json.dumps({
"minimumCornerLuma": minimum_corner_luma,
"maximumCornerLuma": maximum_corner_luma,
"minimumBackgroundSimilarityRatio": minimum_background_similarity_ratio,
"minimumForegroundWidthRatio": minimum_foreground_width_ratio,
"minimumForegroundHeightRatio": minimum_foreground_height_ratio,
"width": width,
"height": height
}))
`
return JSON.parse(execFileSync('python3', ['-', assetPath], {
encoding: 'utf8',
input: script
}).trim())
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixel = pixelAt(x, y)
const diff = pixel.reduce(
(total, value, channel) => total + Math.abs(value - background[channel]),
0
)
if (diff > 22) {
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
if (diff <= 12) {
backgroundSimilarity += 1
}
}
}
if (maxX >= minX && maxY >= minY) {
minimumForegroundWidthRatio = Math.min(minimumForegroundWidthRatio, (maxX - minX + 1) / width)
minimumForegroundHeightRatio = Math.min(minimumForegroundHeightRatio, (maxY - minY + 1) / height)
}
minimumBackgroundSimilarityRatio = Math.min(
minimumBackgroundSimilarityRatio,
backgroundSimilarity / (width * height)
)
}
return {
minimumCornerLuma,
maximumCornerLuma,
minimumBackgroundSimilarityRatio,
minimumForegroundWidthRatio,
minimumForegroundHeightRatio,
width,
height
}
}
const appShell = readSource('../src/views/AppShellRouteView.vue')
@@ -230,7 +232,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /费用测算中,请稍等/)
assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /<article[\s\S]{0,140}v-for="file in runtime\.selectedFileCards"[\s\S]{0,140}class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
@@ -239,7 +241,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
assert.match(aiModeSurface, /const selectedFileCards = computed/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file, previewAsset\)/)
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
@@ -345,7 +347,8 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card-list\) \{[\s\S]*gap:\s*16px;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*url\("\.\.\/\.\.\/ai-document-card-bg\.png"\);/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*background-color:\s*#ffffff;/)
assert.doesNotMatch(aiModeStyles, /ai-document-card-bg\.png/)
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)::before/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: var\(--ai-document-card-head-bg\);/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\.is-success \.ai-document-card__head\)/)
@@ -422,7 +425,8 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-mode\s*\{[\s\S]*min-height:\s*100%;[\s\S]*background:/)
assert.match(aiModeStyles, /\.workbench-ai-mode\.has-conversation\s*\{[\s\S]*place-items:\s*stretch;[\s\S]*padding:\s*0;/)
assert.match(aiModeStyles, /\.workbench-ai-composer\s*\{[\s\S]*border-radius:\s*20px;[\s\S]*box-shadow:/)
assert.match(fileStripRule, /flex-wrap:\s*wrap;/)
assert.match(fileStripRule, /flex-wrap:\s*nowrap;/)
assert.match(fileStripRule, /overflow-x:\s*auto;/)
assert.match(fileStripRule, /justify-content:\s*flex-start;/)
assert.match(fileCardRule, /grid-template-columns:\s*48px minmax\(0,\s*1fr\) 30px;/)
assert.match(fileCardRule, /border-radius:\s*16px;/)
@@ -579,7 +583,7 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
assert.match(aiModeSurface, /watch\(selectedFiles, \(files\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /watch\(selectedFiles, \(files(?:, previousFiles = \[\])?\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)

View File

@@ -451,7 +451,7 @@ test('standalone reimbursement draft branch asks before creating a new draft', (
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
assert.match(
personalWorkbenchAiMode,
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
/import\s*\{\s*isReimbursementCreationIntent\s*\}\s*from '\.\/workbenchAiApplicationGateModel\.js'/
)
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)

View File

@@ -46,7 +46,8 @@ test('workbench document detail keeps workbench as the return target', () => {
assert.match(appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/)
assert.match(appShell, /openRequestDetail\(detailPayload,\s*\{ returnTo \}\)/)
assert.match(appShellComposable, /const detailReturnTarget = computed/)
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
assert.match(appShellComposable, /if \(detailReturnTarget\.value === 'workbench'\) return '返回首页'/)
assert.match(appShellComposable, /detailReturnTarget\.value === 'value' \? '返回经营价值' : '返回单据中心'/)
assert.match(appShellComposable, /const returnTo = resolveDocumentDetailReturnTarget\(options\.returnTo\)/)
assert.match(appShellComposable, /nextQuery\.returnTo = returnTo/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
@@ -57,7 +58,7 @@ test('AI conversation document detail returns to the active conversation content
assert.match(aiDetailReference, /source:\s*'ai-conversation'/)
assert.match(aiDetailReference, /returnTo:\s*'conversation'/)
assert.match(appShell, /@back-to-requests="handleDocumentDetailBack"/)
assert.match(appShell, /const DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation'\]\)/)
assert.match(appShell, /const DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation', 'value'\]\)/)
assert.match(
appShell,
/function handleDocumentDetailBack\(\) \{[\s\S]*detailReturnTarget\.value === 'conversation'[\s\S]*dispatchAiSidebarCommand\('open-recent', activeConversation\)/