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