feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -164,6 +164,7 @@
}
.main.documents-main,
.main.receipt-folder-main,
.main.budget-main,
.main.requests-main,
.main.approval-main,
.main.archive-main,
@@ -183,6 +184,7 @@
.workarea.requests-workarea,
.workarea.documents-workarea,
.workarea.receipt-folder-workarea,
.workarea.budget-workarea,
.workarea.workbench-workarea,
.workarea.approval-workarea,
.workarea.archive-workarea,

View File

@@ -116,6 +116,11 @@
line-height: 1.65;
}
.run-product-muted-copy {
color: #64748b;
font-size: 12px;
}
.run-product-tags {
display: flex;
flex-wrap: wrap;
@@ -234,6 +239,57 @@
background: #f8fafc;
}
.run-product-feedback-panel {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #e5edf6;
border-radius: 4px;
background: #ffffff;
}
.run-product-section-head.compact {
margin-bottom: 8px;
}
.run-product-feedback-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.run-product-feedback-list li {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 8px;
color: #475569;
font-size: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.run-product-feedback-list strong {
color: #0f172a;
font-size: 12px;
}
.run-product-feedback-list span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-product-feedback-list em {
color: #64748b;
font-style: normal;
white-space: nowrap;
}
.risk-level-pill {
display: inline-flex;
align-items: center;

View File

@@ -0,0 +1,568 @@
.employee-risk-profile-card {
display: grid;
gap: 14px;
}
.employee-risk-profile-head {
margin-bottom: 0;
}
.employee-risk-profile-badges {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.ai-assist-badge,
.profile-level-pill {
min-height: 26px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbeafe;
border-radius: 4px;
background: #f8fafc;
color: #2563eb;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.ai-assist-badge i {
font-size: 14px;
}
.profile-level-pill.normal {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.profile-level-pill.watch {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.profile-level-pill.medium {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.profile-level-pill.high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-state {
min-height: 92px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 750;
}
.employee-risk-state.error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-body {
display: grid;
gap: 12px;
}
.employee-risk-decision {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(140px, auto);
gap: 14px;
align-items: center;
padding: 14px;
border: 1px solid #dbeafe;
border-radius: 4px;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
}
.employee-risk-decision.watch {
border-color: #bfdbfe;
}
.employee-risk-decision.medium {
border-color: #fed7aa;
background: linear-gradient(180deg, #fffaf5 0%, #ffffff 100%);
}
.employee-risk-decision.high {
border-color: #fecaca;
background: linear-gradient(180deg, #fff7f7 0%, #ffffff 100%);
}
.employee-risk-score {
width: 76px;
height: 76px;
display: grid;
place-items: center;
align-content: center;
border: 1px solid #bfdbfe;
border-radius: 50%;
background: #eff6ff;
color: #2563eb;
}
.employee-risk-decision.normal .employee-risk-score {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.employee-risk-decision.medium .employee-risk-score {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-decision.high .employee-risk-score {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-score strong {
color: inherit;
font-size: 25px;
font-weight: 900;
line-height: 1;
}
.employee-risk-score span {
margin-top: 4px;
color: inherit;
font-size: 11px;
font-weight: 800;
}
.employee-risk-decision-copy,
.employee-risk-action {
min-width: 0;
display: grid;
gap: 5px;
}
.employee-risk-decision-copy span,
.employee-risk-action span,
.employee-risk-section-head span,
.employee-risk-meta-grid span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.employee-risk-decision-copy strong {
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.employee-risk-decision-copy p {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.65;
}
.employee-risk-action {
padding-left: 14px;
border-left: 1px solid #e2e8f0;
}
.employee-risk-action strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.45;
}
.employee-risk-meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.employee-risk-meta-grid article {
min-width: 0;
display: grid;
gap: 5px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.employee-risk-meta-grid strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
overflow-wrap: anywhere;
}
.employee-risk-analysis-grid {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr);
gap: 12px;
}
.employee-risk-section {
min-width: 0;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.employee-risk-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.employee-risk-section-head small {
color: #94a3b8;
font-size: 11px;
font-weight: 700;
}
.employee-risk-advice-list,
.employee-risk-evidence-list,
.employee-risk-profile-evidence {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.employee-risk-advice-list li {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 8px;
align-items: flex-start;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.employee-risk-advice-list i {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
font-size: 15px;
}
.employee-risk-advice-list li.normal i {
background: #f0fdf4;
color: #15803d;
}
.employee-risk-advice-list li.medium i {
background: #fff7ed;
color: #c2410c;
}
.employee-risk-advice-list li.high i {
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-advice-list span {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 760;
line-height: 1.55;
}
.employee-risk-advice-list strong {
display: inline-block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.employee-risk-evidence-list li {
display: grid;
grid-template-columns: 82px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 9px 0;
border-bottom: 1px solid #edf2f7;
}
.employee-risk-evidence-list li:last-child {
border-bottom: 0;
}
.employee-risk-evidence-list span,
.employee-risk-evidence-list em,
.employee-risk-profile-evidence span {
color: #64748b;
font-size: 12px;
font-style: normal;
font-weight: 760;
}
.employee-risk-evidence-list strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 820;
overflow-wrap: anywhere;
}
.employee-risk-evidence-list small {
grid-column: 2 / 4;
color: #94a3b8;
font-size: 11px;
line-height: 1.45;
}
.employee-risk-dimension-list {
display: grid;
gap: 9px;
}
.employee-risk-dimension {
display: grid;
grid-template-columns: minmax(120px, .65fr) minmax(0, 1fr) 56px;
gap: 10px;
align-items: center;
}
.employee-risk-dimension > div:first-child {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.employee-risk-dimension span,
.employee-risk-dimension small {
color: #64748b;
font-size: 12px;
font-weight: 760;
}
.employee-risk-dimension strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.employee-risk-dimension-track {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
}
.employee-risk-dimension-track i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--theme-primary-active);
}
.employee-risk-profile-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.employee-risk-profile {
min-width: 0;
display: grid;
gap: 10px;
padding: 11px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.employee-risk-profile-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.employee-risk-profile-title div {
min-width: 0;
display: grid;
gap: 3px;
}
.employee-risk-profile-title span {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.employee-risk-profile-title small {
color: #64748b;
font-size: 11px;
font-weight: 760;
}
.employee-risk-profile-title > strong {
min-width: 38px;
min-height: 28px;
display: grid;
place-items: center;
border: 1px solid #dbeafe;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
font-size: 14px;
font-weight: 900;
}
.employee-risk-profile-title > strong.normal {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.employee-risk-profile-title > strong.medium {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-profile-title > strong.high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-profile-evidence li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 7px;
border-top: 1px solid #e2e8f0;
}
.employee-risk-profile-evidence strong {
color: #0f172a;
font-size: 12px;
font-weight: 820;
}
.employee-risk-tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.employee-risk-tag {
min-height: 26px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 9px;
border: 1px solid #dbeafe;
border-radius: 4px;
background: #f8fafc;
color: #334155;
font-size: 12px;
font-weight: 800;
}
.employee-risk-tag strong {
color: inherit;
font-size: 11px;
}
.employee-risk-tag.risk {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-tag.behavior {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.employee-risk-tag.positive {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.employee-risk-muted {
margin: 0;
color: #94a3b8;
font-size: 12px;
line-height: 1.6;
}
@media (max-width: 1180px) {
.employee-risk-decision,
.employee-risk-analysis-grid,
.employee-risk-profile-list {
grid-template-columns: 1fr;
}
.employee-risk-action {
padding-left: 0;
padding-top: 12px;
border-left: 0;
border-top: 1px solid #e2e8f0;
}
}
@media (max-width: 760px) {
.employee-risk-meta-grid,
.employee-risk-dimension {
grid-template-columns: 1fr;
}
.employee-risk-profile-head,
.employee-risk-section-head {
align-items: flex-start;
flex-direction: column;
}
.employee-risk-evidence-list li {
grid-template-columns: 1fr;
}
.employee-risk-evidence-list small {
grid-column: auto;
}
}

View File

@@ -1,3 +1,42 @@
.detail-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.detail-card h3 {
margin: 0 0 12px;
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.detail-card-head h3 {
margin-bottom: 4px;
}
.detail-card-title-with-icon {
display: inline-flex;
align-items: center;
gap: 8px;
line-height: 1.5;
}
.detail-card-title-with-icon i {
margin-top: 1px;
color: #334155;
font-size: 18px;
line-height: 1;
}
.detail-card-head p {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.risk-observation-evidence-card {
display: grid;
gap: 14px;

View File

@@ -37,6 +37,7 @@
.risk-sim-result-head span,
.risk-sim-evidence span,
.risk-sim-recognition-debug > span,
.risk-sim-field-pipeline header span,
.risk-sim-recognized-fields > span,
.risk-sim-file-strip > span {
color: #64748b;
@@ -491,6 +492,48 @@
white-space: nowrap;
}
.risk-sim-field-pipeline {
display: grid; gap: 8px;
padding: 0 12px 12px;
}
.risk-sim-field-pipeline section {
display: grid;
gap: 6px;
padding: 9px;
border: 1px solid #edf2f7;
border-radius: 10px;
background: #f8fafc;
}
.risk-sim-field-pipeline header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.risk-sim-field-pipeline small {
color: #64748b; font-size: 11px;
}
.risk-sim-field-pipeline ul {
display: grid; gap: 5px;
margin: 0;
padding: 0;
list-style: none;
}
.risk-sim-field-pipeline li {
display: grid;
grid-template-columns: minmax(132px, 0.7fr) minmax(92px, auto) minmax(130px, 1fr);
gap: 7px; color: #334155;
font-size: 12px;
}
.risk-sim-field-pipeline strong,
.risk-sim-field-pipeline b,
.risk-sim-field-pipeline em {
min-width: 0;
overflow: hidden;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-sim-evidence {
display: grid;
gap: 6px;

View File

@@ -290,16 +290,33 @@
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
}
.detail-kpi-chips {
justify-content: flex-end;
gap: 8px;
}
.detail-kpi-chip {
min-width: 142px;
min-width: 112px;
padding: 6px 10px;
gap: 1px 8px;
}
.detail-kpi-chip .chip-value {
font-size: 16px;
font-weight: 820;
letter-spacing: 0;
}
.detail-kpi-chip .chip-label {
font-size: 11px;
}
.detail-kpi-chip .chip-delta {
font-size: 10px;
}
.detail-alert-strip {

View File

@@ -74,11 +74,11 @@
.application-draft-brief {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
gap: 1px;
border: 1px solid #d7e4f2;
border-radius: 4px;
overflow: hidden;
background: #ffffff;
background: #d7e4f2;
}
.application-draft-brief-item {
@@ -88,21 +88,15 @@
align-items: center;
min-height: 42px;
padding: 8px 12px;
border-top: 1px solid #edf2f7;
border-left: 1px solid #edf2f7;
border: 0;
background: #ffffff;
font-size: 12px;
}
.application-draft-brief-item:nth-child(even) {
border-left: 0;
}
.application-draft-brief-item.is-primary {
grid-column: 1 / -1;
grid-template-columns: 42px minmax(0, 1fr);
min-height: 48px;
border-top: 0;
border-left: 0;
background: #f8fbff;
}
@@ -180,12 +174,7 @@
}
.application-draft-brief-item {
border-left: 0;
border-top: 1px solid #edf2f7;
}
.application-draft-brief-item.is-primary {
border-top: 0;
border: 0;
}
}

View File

@@ -0,0 +1,496 @@
.agent-trace-center {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
}
.trace-filters,
.trace-list,
.trace-detail {
border: 1px solid rgba(148, 163, 184, 0.18);
}
.trace-kicker {
color: var(--theme-primary-active);
font-size: 11px;
font-weight: 800;
letter-spacing: .06em;
text-transform: uppercase;
}
.trace-detail-head h4 {
margin: 0;
color: var(--ink);
font-size: 18px;
line-height: 1.3;
}
.trace-detail-head p,
.trace-state p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.trace-mini-action {
min-height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--line);
border-radius: 4px;
background: #fff;
color: var(--text);
font-size: 12px;
font-weight: 800;
}
.trace-mini-action:hover {
border-color: rgba(var(--theme-primary-rgb), 0.28);
color: var(--theme-primary-active);
}
.trace-filters {
display: grid;
grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(130px, .7fr));
gap: 10px;
padding: 12px;
}
.trace-field {
display: grid;
gap: 6px;
}
.trace-field span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.trace-field input,
.trace-field select {
width: 100%;
height: 34px;
border: 1px solid var(--line-strong);
border-radius: 4px;
background: #fff;
color: var(--text);
font-size: 13px;
}
.trace-field input {
padding: 0 10px;
}
.trace-field select {
padding: 0 8px;
}
.trace-layout {
min-height: 0;
display: grid;
grid-template-columns: minmax(520px, .95fr) minmax(520px, 1.05fr);
gap: 12px;
}
.trace-list,
.trace-detail {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.trace-list-head {
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 14px;
border-bottom: 1px solid var(--line);
}
.trace-list-head div {
display: flex;
align-items: baseline;
gap: 8px;
}
.trace-list-head strong {
color: var(--ink);
font-size: 14px;
}
.trace-list-head span {
color: var(--muted);
font-size: 12px;
}
.trace-list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.trace-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.trace-table th {
height: 36px;
padding: 0 12px;
border-bottom: 1px solid var(--line);
background: #f8fafc;
color: #475569;
font-weight: 800;
text-align: left;
}
.trace-table td {
min-height: 54px;
padding: 10px 12px;
border-bottom: 1px solid #edf2f7;
color: var(--text);
vertical-align: middle;
}
.trace-table tr {
cursor: pointer;
}
.trace-table tr:hover td,
.trace-table tr.active td {
background: var(--theme-primary-light-9);
}
.trace-table td strong,
.trace-table td span {
display: block;
}
.trace-table td strong {
max-width: 280px;
overflow: hidden;
color: var(--ink);
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-table td span {
margin-top: 3px;
color: var(--muted);
}
.trace-run-id {
max-width: 160px;
color: var(--theme-primary-active) !important;
font-family: "SFMono-Regular", Consolas, monospace;
}
.trace-status {
display: inline-flex;
min-height: 24px;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 4px;
border: 1px solid var(--line);
background: #f8fafc;
color: var(--muted);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.trace-status.success {
border-color: var(--success-line);
background: var(--success-soft);
color: var(--success);
}
.trace-status.warning {
border-color: var(--warning-line);
background: var(--warning-soft);
color: var(--warning);
}
.trace-status.danger {
border-color: var(--danger-line);
background: var(--danger-soft);
color: var(--danger);
}
.trace-status.info {
border-color: rgba(var(--theme-primary-rgb), 0.18);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.trace-status.mini {
min-height: 22px;
font-size: 11px;
}
.trace-state {
min-height: 220px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 24px;
color: var(--muted);
text-align: center;
}
.trace-state i {
color: var(--theme-primary-active);
font-size: 28px;
}
.trace-state strong {
color: var(--ink);
font-size: 14px;
}
.trace-state.error i,
.trace-state.error strong {
color: var(--danger);
}
.trace-detail-head {
min-height: 92px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.trace-detail-head > div {
min-width: 0;
display: grid;
gap: 5px;
}
.trace-inline-alert,
.trace-error-text {
margin: 12px 14px 0;
padding: 10px 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.55;
}
.trace-inline-alert {
border: 1px solid var(--warning-line);
background: var(--warning-soft);
color: var(--warning-active);
}
.trace-error-text {
border: 1px solid var(--danger-line);
background: var(--danger-soft);
color: var(--danger-active);
}
.trace-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 12px 14px;
}
.trace-metrics div {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 4px;
background: #f8fafc;
}
.trace-metrics span,
.trace-metrics strong {
display: block;
}
.trace-metrics span {
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.trace-metrics strong {
margin-top: 4px;
overflow: hidden;
color: var(--ink);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-detail-grid {
min-height: 0;
display: grid;
grid-template-columns: minmax(260px, .8fr) minmax(340px, 1.2fr);
gap: 10px;
padding: 0 14px 14px;
overflow: hidden;
}
.trace-event-list {
min-height: 0;
overflow: auto;
display: grid;
align-content: start;
gap: 8px;
padding-right: 2px;
}
.trace-event {
width: 100%;
min-height: 56px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: #fff;
text-align: left;
}
.trace-event:hover,
.trace-event.active {
border-color: rgba(var(--theme-primary-rgb), 0.28);
background: var(--theme-primary-light-9);
}
.event-index {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 900;
}
.event-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.event-copy strong,
.event-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-copy strong {
color: var(--ink);
font-size: 12px;
}
.event-copy small {
color: var(--muted);
font-size: 11px;
}
.trace-event-payload {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 4px;
background: #fff;
}
.payload-head {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--line);
}
.payload-head div {
min-width: 0;
display: grid;
gap: 4px;
}
.payload-head strong {
color: var(--ink);
font-size: 13px;
}
.payload-head span {
color: var(--muted);
font-size: 12px;
}
.payload-columns {
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
overflow: hidden;
}
.payload-columns > div {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
}
.payload-columns > div:last-child {
border-right: 0;
}
.payload-columns h5 {
margin: 0;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
color: var(--ink);
font-size: 12px;
}
.payload-columns pre {
min-height: 260px;
max-height: 460px;
margin: 0;
padding: 12px;
overflow: auto;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.55;
}
@media (max-width: 1180px) {
.trace-filters,
.trace-layout,
.trace-detail-grid,
.payload-columns {
grid-template-columns: 1fr;
}
}

View File

@@ -1035,6 +1035,12 @@
color: var(--success-hover);
}
.minor-action.primary-action {
border-color: rgba(37, 99, 235, 0.28);
color: #1d4ed8;
background: #f8fbff;
}
.minor-action.enable-action {
border-color: rgba(100, 116, 139, 0.26);
color: #64748b;

File diff suppressed because it is too large Load Diff

View File

@@ -119,6 +119,14 @@
line-height: 1.6;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.refresh-btn {
min-height: 38px;
display: inline-flex;

View File

@@ -45,7 +45,8 @@
}
.receipt-key-grid input,
.receipt-edit-field-row input {
.receipt-edit-field-row input,
.receipt-ocr-field input {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 4px;
@@ -56,13 +57,15 @@
}
.receipt-key-grid input,
.receipt-edit-field-row input {
.receipt-edit-field-row input,
.receipt-ocr-field input {
height: 36px;
padding: 0 10px;
}
.receipt-key-grid input:focus,
.receipt-edit-field-row input:focus {
.receipt-edit-field-row input:focus,
.receipt-ocr-field input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
@@ -122,6 +125,73 @@
padding-top: 10px;
}
.receipt-detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
}
.receipt-detail-title {
min-width: 0;
display: grid;
gap: 3px;
}
.receipt-detail-title strong {
color: #0f172a;
font-size: 18px;
font-weight: 850;
}
.receipt-detail-title span {
color: #0f172a;
font-size: 13px;
font-weight: 780;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receipt-detail-title p {
margin: 0;
color: #64748b;
font-size: 12px;
}
.receipt-toolbar-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.receipt-dashboard {
min-height: 0;
display: grid;
grid-template-columns: minmax(420px, 0.92fr) minmax(520px, 1.08fr);
gap: 14px;
align-items: stretch;
}
.receipt-dashboard-side {
min-height: 0;
display: grid;
gap: 14px;
}
.receipt-dashboard-bottom {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(260px, 0.95fr) minmax(320px, 1.2fr) minmax(240px, 0.85fr);
gap: 14px;
}
.receipt-folder-detail :deep(.detail-grid) {
min-height: 0;
display: grid;
@@ -159,7 +229,11 @@
}
.receipt-basic-panel,
.receipt-preview-panel {
.receipt-preview-panel,
.receipt-ocr-panel,
.receipt-status-panel,
.receipt-info-panel,
.receipt-log-panel {
min-height: 0;
overflow: hidden;
border: 1px solid #dbe4ee;
@@ -170,7 +244,7 @@
.receipt-basic-panel {
display: block;
padding: 14px;
overflow-y: auto;
overflow: hidden;
}
.receipt-field-list-head {
@@ -193,22 +267,106 @@
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.receipt-key-field,
.receipt-edit-field-row label {
.receipt-edit-field-row label,
.receipt-ocr-field {
display: grid;
gap: 6px;
}
.receipt-key-field span,
.receipt-edit-field-row label span {
.receipt-edit-field-row label span,
.receipt-ocr-field span,
.receipt-static-item span,
.receipt-data-item span,
.receipt-status-item span {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.receipt-card-count {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.receipt-static-grid,
.receipt-ocr-grid,
.receipt-status-grid,
.receipt-data-list {
display: grid;
gap: 10px;
}
.receipt-static-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #edf2f7;
}
.receipt-static-item,
.receipt-data-item,
.receipt-status-item {
min-width: 0;
display: grid;
gap: 4px;
}
.receipt-static-item strong,
.receipt-data-item strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 780;
line-height: 1.45;
overflow-wrap: anywhere;
}
.receipt-ocr-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 12px;
}
.receipt-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.receipt-status-item {
grid-template-columns: minmax(90px, 1fr) auto;
align-items: center;
min-height: 30px;
}
.receipt-status-item strong {
min-height: 24px;
display: inline-flex;
align-items: center;
justify-self: start;
padding: 0 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.receipt-status-item .tone-success {
background: var(--success-soft);
color: var(--success-active);
}
.receipt-status-item .tone-warning {
background: #fff7ed;
color: #ea580c;
}
.receipt-status-item .tone-info {
background: #eff6ff;
color: #2563eb;
}
.receipt-other-info {
margin-top: 18px;
}
@@ -288,21 +446,32 @@
.receipt-preview-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto;
padding: 14px;
}
.receipt-preview-frame {
min-height: 0;
padding: 10px;
border: 1px solid #e5edf5;
border-radius: 4px;
background: #fff;
}
.receipt-preview-box {
min-height: 0;
min-height: 340px;
display: grid;
place-items: center;
overflow: auto;
background: #f8fafc;
background: #f7fbff;
}
.receipt-preview-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center center;
transition: transform 180ms ease;
}
.receipt-preview-box iframe {
@@ -325,6 +494,113 @@
font-size: 34px;
}
.receipt-preview-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 12px;
}
.preview-page,
.preview-tool-group {
min-height: 32px;
display: inline-flex;
align-items: center;
}
.preview-page {
padding: 0 12px;
border: 1px solid #e1e8f0;
border-radius: 4px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.preview-tool-group {
gap: 6px;
}
.preview-tool-group button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #e1e8f0;
border-radius: 4px;
background: #fff;
color: #334155;
}
.preview-tool-group button:disabled {
cursor: not-allowed;
opacity: .45;
}
.preview-tool-group strong {
min-width: 48px;
text-align: center;
color: #0f172a;
font-size: 12px;
font-weight: 800;
}
.receipt-log-list {
position: relative;
display: grid;
gap: 10px;
margin: 0;
padding: 0 0 0 16px;
list-style: none;
}
.receipt-log-list::before {
content: "";
position: absolute;
left: 4px;
top: 6px;
bottom: 6px;
width: 1px;
background: #dbe4ee;
}
.receipt-log-list li {
position: relative;
display: grid;
grid-template-columns: 120px 54px minmax(0, 1fr);
gap: 8px;
align-items: start;
color: #334155;
font-size: 12px;
}
.receipt-log-list li::before {
content: "";
position: absolute;
left: -15px;
top: 5px;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--theme-primary);
}
.receipt-log-list span {
color: #64748b;
font-variant-numeric: tabular-nums;
}
.receipt-log-list strong {
color: #0f172a;
font-weight: 780;
}
.receipt-log-list p {
margin: 0;
line-height: 1.45;
}
.associate-step {
display: grid;
gap: 12px;
@@ -387,6 +663,8 @@
}
@media (max-width: 1120px) {
.receipt-dashboard,
.receipt-dashboard-bottom,
.receipt-folder-detail :deep(.detail-grid) {
grid-template-columns: 1fr;
overflow-y: auto;
@@ -402,8 +680,22 @@
padding: 12px;
}
.receipt-detail-toolbar,
.receipt-toolbar-actions,
.receipt-preview-tools {
align-items: stretch;
flex-direction: column;
}
.receipt-key-grid,
.receipt-edit-field-row {
.receipt-edit-field-row,
.receipt-static-grid,
.receipt-ocr-grid,
.receipt-status-grid {
grid-template-columns: 1fr;
}
.receipt-log-list li {
grid-template-columns: 1fr;
}
}

View File

@@ -80,15 +80,19 @@
.validation-section--risk .risk-advice-list {
display: grid;
gap: 10px;
gap: 8px;
margin-top: 0;
max-height: 360px;
padding-right: 4px;
overflow-y: auto;
scrollbar-gutter: stable;
}
.validation-section--risk .risk-advice-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
justify-content: flex-start;
gap: 8px;
}
.validation-section--risk .risk-advice-card-head span {
@@ -96,7 +100,7 @@
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 4px;
border-radius: 2px;
background: #fef2f2;
color: #b91c1c;
font-size: 10px;
@@ -117,9 +121,13 @@
.validation-section--risk .risk-advice-card-head strong {
min-width: 0;
color: #0f172a;
font-size: 12px;
line-height: 1.4;
text-align: right;
font-size: 13px;
font-weight: 850;
line-height: 1.45;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.validation-section--risk .risk-advice-point {
@@ -172,7 +180,7 @@
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 4px;
border-radius: 2px;
background: #fee2e2;
color: #b91c1c;
font-size: 11px;

View File

@@ -391,11 +391,12 @@
}
.progress-step-status {
max-width: 100%;
box-sizing: border-box;
width: 100%;
max-width: 136px;
min-width: 0;
min-height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
display: block;
padding: 0 9px;
border: 1px solid #e2e8f0;
border-radius: 4px;
@@ -403,7 +404,7 @@
color: #64748b;
font-size: 11px;
font-weight: 850;
line-height: 1;
line-height: 20px;
text-align: center;
white-space: nowrap;
overflow: hidden;
@@ -1775,31 +1776,149 @@
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.validation-section--risk .risk-advice-card {
.validation-section--risk .risk-advice-list {
display: grid;
gap: 8px;
padding: 12px 12px 11px;
max-height: 360px;
padding-right: 4px;
overflow-y: auto;
scrollbar-gutter: stable;
}
.validation-section--risk .risk-advice-card {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(220px, .9fr);
align-items: start;
gap: 12px;
min-height: 64px;
padding: 10px 12px 10px 14px;
border: 1px solid #e5e7eb;
border-radius: 4px;
border-radius: 2px;
background: #ffffff;
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03);
}
.validation-section--risk .risk-advice-card::before {
content: "";
position: absolute;
inset: 10px auto 10px 7px;
width: 3px;
border-radius: 1px;
background: #ef4444;
}
.validation-section--risk .risk-advice-card.medium {
border-color: #f3e8d9;
background: #fffcf7;
}
.validation-section--risk .risk-advice-card.medium::before {
background: #f97316;
}
.validation-section--risk .risk-advice-card.low {
border-color: #dbeafe;
background: #f8fbff;
}
.validation-section--risk .risk-advice-meta ul,
.validation-section--risk .risk-advice-meta p {
.validation-section--risk .risk-advice-card.low::before {
background: #2563eb;
}
.risk-advice-card-main,
.risk-advice-compact-meta {
min-width: 0;
}
.risk-advice-card-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.risk-advice-card-head span {
flex: 0 0 auto;
height: 20px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border: 1px solid #fecaca;
border-radius: 2px;
background: #fef2f2;
color: #b91c1c;
font-size: 10px;
font-weight: 850;
line-height: 1;
}
.risk-advice-card.medium .risk-advice-card-head span {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.risk-advice-card.low .risk-advice-card-head span {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.risk-advice-card-head strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-advice-point {
display: -webkit-box;
margin: 5px 0 0;
color: #334155;
font-size: 12px;
line-height: 1.5;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.risk-advice-compact-meta {
display: grid;
gap: 4px;
padding-left: 12px;
border-left: 1px solid #e2e8f0;
}
.risk-advice-compact-meta span,
.risk-advice-compact-meta em {
margin: 0;
color: #334155;
font-size: 11px;
line-height: 1.5;
font-style: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-advice-compact-meta span {
color: #64748b;
}
@media (max-width: 1180px) {
.validation-section--risk .risk-advice-card {
grid-template-columns: 1fr;
gap: 8px;
}
.risk-advice-compact-meta {
padding-left: 0;
border-left: 0;
}
}

View File

@@ -12,7 +12,18 @@
:loading-message="`正在加载${activeTabLabel}资产`"
loading-icon="mdi mdi-view-list-outline"
:hint="hintText"
:show-pagination="!loading && !errorMessage && visibleSkills.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleSkills.length"
:total-pages="totalPages"
@update:active-tab="emit('update:activeType', $event)"
@update:current-page="currentPage = $event"
@update:page-size="changePageSize"
@empty-action="emit('empty-action')"
>
<template #filters>
@@ -178,7 +189,7 @@
</thead>
<tbody>
<tr
v-for="skill in visibleSkills"
v-for="skill in pagedSkills"
:key="skill.id"
:class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }"
@click="emit('open-asset-detail', skill)"
@@ -221,17 +232,11 @@
</tbody>
</table>
</template>
<template #footer>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer>
</template>
</EnterpriseListPage>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
@@ -310,9 +315,66 @@ const shellTabs = computed(() =>
}))
)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizeOptions = [10, 20, 50].map((size) => ({
label: `${size} 条/页`,
value: size
}))
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleSkills.length / pageSize.value)))
const pagedSkills = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return props.visibleSkills.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
)
watch(
() => [
props.activeType,
props.keyword,
props.selectedDomain,
props.selectedOwner,
props.selectedRiskLevel,
props.selectedStatus,
props.selectedRiskScenario,
props.selectedOnlineState,
props.selectedEnabledState
],
() => {
currentPage.value = 1
}
)
watch(
() => props.visibleSkills.length,
() => {
currentPage.value = Math.min(currentPage.value, totalPages.value)
if (currentPage.value < 1) {
currentPage.value = 1
}
},
{ immediate: true }
)
function selectFilter(type, value) {
emit('select-filter', type, value)
}
function changePageSize(size) {
const nextSize = Number(size) || 10
pageSize.value = nextSize
currentPage.value = 1
}
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>

View File

@@ -16,6 +16,30 @@
@confirm="emit('submit-risk-rule-create')"
>
<div class="risk-rule-create-form">
<label class="span-2">
<span>常见规则模板</span>
<EnterpriseSelect
v-model="selectedRiskRuleTemplateId"
:options="riskRuleTemplateOptions"
:disabled="riskRuleCreateBusy || riskRuleTemplatesLoading"
:placeholder="riskRuleTemplatesLoading ? '模板加载中...' : '可选:从常见规则预填'"
clearable
filterable
@change="applyRiskRuleTemplate"
/>
</label>
<div v-if="selectedRiskRuleTemplate" class="risk-rule-template-preview span-2">
<div class="risk-rule-template-preview__head">
<strong>{{ selectedRiskRuleTemplate.title }}</strong>
<span>{{ selectedRiskRuleTemplate.group_label }}</span>
</div>
<p>{{ selectedRiskRuleTemplate.description }}</p>
<small>字段{{ formatTemplateFields(selectedRiskRuleTemplate.fields) }}</small>
<small>模板只预填规则文本提交后仍走通用自然语言生成链路</small>
</div>
<p v-else-if="riskRuleTemplateLoadFailed" class="risk-rule-template-note span-2">
常见模板暂时加载失败可以继续手动编写规则
</p>
<label>
<span>业务环节</span>
<EnterpriseSelect
@@ -72,9 +96,9 @@
badge="规则维护"
badge-tone="info"
:title="riskRuleEditMode === 'revision' ? '创建修订版本' : '编辑风险规则'"
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述。'"
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿并重新生成执行模板。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述,并同步重新生成执行模板。'"
cancel-text="取消"
:confirm-text="riskRuleEditMode === 'revision' ? '创建修订' : '保存草稿'"
:confirm-text="riskRuleEditMode === 'revision' ? '创建并生成' : '保存并生成'"
busy-text="保存中..."
confirm-tone="primary"
confirm-icon="mdi mdi-content-save-outline"
@@ -280,10 +304,11 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import RiskRuleTestDialog from '../shared/RiskRuleTestDialog.vue'
import { fetchRiskRuleTemplates } from '../../services/agentAssets.js'
defineOptions({
name: 'AuditRuleDialogs'
@@ -354,7 +379,123 @@ const reviewSubmitReviewerModel = computed({
get: () => props.reviewSubmitReviewer,
set: (value) => emit('update:reviewSubmitReviewer', value)
})
const riskRuleTemplateGroups = ref([])
const riskRuleTemplatesLoading = ref(false)
const riskRuleTemplateLoadFailed = ref(false)
const selectedRiskRuleTemplateId = ref('')
const flatRiskRuleTemplates = computed(() =>
riskRuleTemplateGroups.value.flatMap((group) => group.templates || [])
)
const riskRuleTemplateOptions = computed(() =>
flatRiskRuleTemplates.value.map((template) => ({
value: template.template_id,
label: `${template.group_label} / ${template.title}`
}))
)
const selectedRiskRuleTemplate = computed(() =>
flatRiskRuleTemplates.value.find((template) => template.template_id === selectedRiskRuleTemplateId.value) || null
)
watch(
() => props.riskRuleCreateOpen,
(open) => {
if (!open) {
selectedRiskRuleTemplateId.value = ''
return
}
loadRiskRuleTemplates()
}
)
async function loadRiskRuleTemplates() {
if (riskRuleTemplateGroups.value.length || riskRuleTemplatesLoading.value) {
return
}
riskRuleTemplatesLoading.value = true
riskRuleTemplateLoadFailed.value = false
try {
riskRuleTemplateGroups.value = await fetchRiskRuleTemplates()
} catch {
riskRuleTemplateLoadFailed.value = true
} finally {
riskRuleTemplatesLoading.value = false
}
}
function applyRiskRuleTemplate(templateId) {
const template = flatRiskRuleTemplates.value.find((item) => item.template_id === templateId)
if (!template || !props.riskRuleCreateForm) {
return
}
props.riskRuleCreateForm.business_domain = template.business_domain || 'expense'
props.riskRuleCreateForm.business_stage = template.business_stage || 'reimbursement'
props.riskRuleCreateForm.expense_category = template.expense_category || props.riskRuleCreateForm.expense_category
props.riskRuleCreateForm.rule_title = template.title || ''
props.riskRuleCreateForm.requires_attachment = Boolean(template.requires_attachment)
props.riskRuleCreateForm.natural_language = template.natural_language || ''
}
function formatTemplateFields(fields = []) {
const labels = fields.map((field) => field.display || field.label || field.key).filter(Boolean)
if (!labels.length) {
return '未配置字段'
}
if (labels.length <= 4) {
return labels.join('、')
}
return `${labels.slice(0, 4).join('、')}${labels.length}`
}
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
<style scoped>
.risk-rule-create-form > .span-2 {
grid-column: 1 / -1;
}
.risk-rule-template-preview {
display: grid;
gap: 7px;
padding: 12px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #f8fafc;
}
.risk-rule-template-preview__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.risk-rule-template-preview__head strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.risk-rule-template-preview__head span,
.risk-rule-template-preview small,
.risk-rule-template-note {
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.risk-rule-template-preview p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.6;
}
.risk-rule-template-note {
margin: 0;
}
</style>

View File

@@ -9,8 +9,9 @@
:show-pagination="!loading && !errorMessage && visibleEmployees.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="false"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleEmployees.length"
:total-pages="totalPages"
@@ -18,6 +19,8 @@
loading-message="正在加载数字员工资产"
loading-icon="mdi mdi-view-list-outline"
@update:current-page="currentPage = $event"
@update:page-size="pageSize = $event"
@page-size-change="changePageSize"
>
<template #filters>
<label class="list-search">
@@ -43,6 +46,19 @@
@select="selectFilter('status', $event)"
/>
<AuditPickerFilter
id="skillCategory"
title="选择技能类型"
close-label="关闭技能类型选择"
:active-filter-popover="activeFilterPopover"
:label="selectedSkillCategoryLabel"
:options="skillCategoryOptions"
:selected-value="selectedSkillCategory"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('skillCategory', $event)"
/>
<AuditPickerFilter
id="enabled"
title="选择启动状态"
@@ -172,6 +188,9 @@ const props = defineProps({
selectedStatus: { type: String, default: '' },
selectedStatusLabel: { type: String, default: '' },
statusOptions: { type: Array, default: () => [] },
selectedSkillCategory: { type: String, default: '' },
selectedSkillCategoryLabel: { type: String, default: '' },
skillCategoryOptions: { type: Array, default: () => [] },
selectedEnabledState: { type: String, default: '' },
selectedEnabledLabel: { type: String, default: '' },
enabledStateOptions: { type: Array, default: () => [] },
@@ -195,11 +214,16 @@ const emit = defineEmits([
])
const currentPage = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize)))
const pageSize = ref(10)
const pageSizeOptions = [
{ label: '10 条/页', value: 10 },
{ label: '20 条/页', value: 20 },
{ label: '50 条/页', value: 50 }
]
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize.value)))
const pagedEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize
return props.visibleEmployees.slice(start, start + pageSize)
const start = (currentPage.value - 1) * pageSize.value
return props.visibleEmployees.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
@@ -210,7 +234,7 @@ const pageNumbers = computed(() => {
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleEmployees.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
const emptyState = {
eyebrow: '数字员工',
@@ -223,7 +247,13 @@ const emptyState = {
}
watch(
() => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode],
() => [
props.keyword,
props.selectedStatus,
props.selectedSkillCategory,
props.selectedEnabledState,
props.selectedExecutionMode
],
() => {
currentPage.value = 1
}
@@ -243,6 +273,11 @@ watch(
function selectFilter(type, value) {
emit('select-filter', type, value)
}
function changePageSize(size) {
pageSize.value = Number(size) || 10
currentPage.value = 1
}
</script>
<style scoped src="../../assets/styles/views/digital-employees-view.css"></style>

View File

@@ -110,6 +110,62 @@
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察</p>
</section>
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
<div class="run-product-section-head">
<h4>待复核线索</h4>
<span>{{ riskClues.length }} </span>
</div>
<div v-if="riskClues.length" class="run-product-observation-list">
<article
v-for="item in riskClues"
:key="item.clue_id || item.clueId"
class="run-product-observation"
>
<div class="run-product-observation-head">
<span class="risk-level-pill" :class="item.risk_level || item.riskLevel">
{{ formatRiskLevel(item.risk_level || item.riskLevel) }}
</span>
<strong>{{ item.title || formatSignal(item.risk_signal || item.riskSignal) }}</strong>
<b>{{ formatConfidence(item.confidence_score ?? item.confidenceScore) }}</b>
</div>
<p>{{ item.summary || '该线索需要人工复核事实和证据。' }}</p>
<div class="run-product-tags">
<span>单据{{ item.claim_no || item.claimNo || '-' }}</span>
<span>状态待人工复核</span>
<span v-if="item.observation_key || item.observationKey">
观察{{ item.observation_key || item.observationKey }}
</span>
<span v-if="item.feedback_status || item.feedbackStatus">
反馈{{ formatFeedbackStatus(item.feedback_status || item.feedbackStatus) }}
</span>
<span>规则命中{{ (item.rule_hits || item.ruleHits || []).length }}</span>
<span>证据{{ (item.evidence_refs || item.evidenceRefs || []).length }}</span>
</div>
<p v-if="item.review_reason || item.reviewReason" class="run-product-muted-copy">
{{ item.review_reason || item.reviewReason }}
</p>
<p v-if="item.next_action || item.nextAction" class="run-product-muted-copy">
{{ item.next_action || item.nextAction }}
</p>
</article>
</div>
<p v-else class="run-product-inline-empty">本次运行没有整理出待复核线索</p>
<div v-if="feedbackRows.length" class="run-product-feedback-panel">
<div class="run-product-section-head compact">
<h4>反馈样本</h4>
<span>{{ feedbackTotal }} </span>
</div>
<ul class="run-product-feedback-list">
<li v-for="item in feedbackRows" :key="item.feedback_id || item.feedbackId">
<strong>{{ formatFeedbackStatus(item.feedback_type || item.feedbackType) }}</strong>
<span>{{ item.observation_key || item.observationKey || '未关联观察' }}</span>
<em>{{ item.actor || '系统' }}</em>
</li>
</ul>
</div>
</section>
<section v-else-if="productKind === 'employee_profile'" class="run-product-section">
<div class="run-product-section-head">
<h4>画像快照</h4>
@@ -180,6 +236,9 @@ const productSubtitle = computed(() => {
if (productKind.value === 'knowledge') {
return '展示本次知识制度整理任务的入队结果与处理范围。'
}
if (productKind.value === 'risk_clue') {
return '展示本次任务归集的事实、规则命中和待人工复核线索。'
}
return '展示本次数字员工任务产生的结构化结果。'
})
const productBadge = computed(() => {
@@ -192,8 +251,23 @@ const productBadge = computed(() => {
if (productKind.value === 'knowledge') {
return '知识整理'
}
if (productKind.value === 'risk_clue') {
return '线索归集'
}
return taskLabel.value
})
const riskClues = computed(() =>
Array.isArray(summary.value.risk_clues) ? summary.value.risk_clues : []
)
const feedbackSummary = computed(() =>
summary.value.feedback_summary && typeof summary.value.feedback_summary === 'object'
? summary.value.feedback_summary
: {}
)
const feedbackRows = computed(() =>
Array.isArray(feedbackSummary.value.recent) ? feedbackSummary.value.recent.slice(0, 6) : []
)
const feedbackTotal = computed(() => Number(feedbackSummary.value.total || feedbackRows.value.length || 0))
const documentCount = computed(() =>
Array.isArray(summary.value.document_ids) ? summary.value.document_ids.length : 0
)
@@ -223,6 +297,14 @@ const metrics = computed(() => {
buildMetric('后台 Run ID', payload.agent_run_id || '-')
]
}
if (productKind.value === 'risk_clue') {
return [
buildMetric('事实', payload.fact_count ?? (payload.facts || []).length),
buildMetric('规则命中', payload.rule_hit_count ?? (payload.rule_hits || []).length),
buildMetric('待复核线索', payload.risk_clue_count ?? riskClues.value.length),
buildMetric('反馈样本', feedbackTotal.value)
]
}
return []
})
@@ -387,6 +469,31 @@ function formatRiskLevel(value) {
return labels[String(value || '').trim()] || '未知风险'
}
function formatConfidence(value) {
const numericValue = Number(value || 0)
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return '-'
}
return `${Math.round(numericValue * 100)}%`
}
function formatFeedbackStatus(value) {
const labels = {
unreviewed: '未复核',
pending_review: '待复核',
confirm: '已确认',
confirmed: '已确认',
false_positive: '误报',
ignore: '已忽略',
ignored: '已忽略',
resolve: '已处理',
resolved: '已处理',
comment: '备注'
}
const normalized = String(value || '').trim()
return labels[normalized] || normalized || '未复核'
}
function formatSignal(value) {
const labels = {
duplicate_invoice: '重复发票',

View File

@@ -270,6 +270,10 @@
<span>返回工作记录列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
@@ -283,6 +287,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
@@ -317,6 +322,7 @@ const props = defineProps({
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
const router = useRouter()
const runs = ref([])
const loading = ref(false)
const errorMessage = ref('')
@@ -607,6 +613,14 @@ function closeWorkRecordDetail() {
detailError.value = ''
}
function openTraceCenter() {
const runId = String(selectedRunDetail.value?.run_id || selectedRunId.value || '').trim()
if (!runId) {
return
}
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
}
watch(
() => props.focusRunId,
(runId) => {

View File

@@ -5,14 +5,14 @@
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { GridComponent, LegendComponent, 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'
use([GridComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
@@ -60,17 +60,13 @@ const availablePercent = computed(() =>
props.budget.map((total, index) => percent(availableAmountSeries.value[index], total))
)
const yAxisMax = computed(() => {
const maxUsage = Math.max(
100,
...usedPercent.value.map((value, index) => value + Number(occupiedPercent.value[index] || 0))
)
return Math.ceil(maxUsage / 20) * 20
})
const usagePercent = computed(() =>
usedPercent.value.map((value, index) => Number((value + Number(occupiedPercent.value[index] || 0)).toFixed(2)))
)
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}预算${currency(props.budget[index])},已使用${usedPercent.value[index] || 0}%,已占用${occupiedPercent.value[index] || 0}%`
`${label}预算${currency(props.budget[index])},已发生${usedPercent.value[index] || 0}%,已占用${occupiedPercent.value[index] || 0}%,剩余${availablePercent.value[index] || 0}%`
)).join('')
)
@@ -84,27 +80,43 @@ function buildSeriesData(percentValues, amountValues) {
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: {
duration: prefersReducedMotion() ? 0 : 760,
easing: 'easeOutQuart'
duration: prefersReducedMotion() ? 0 : 820,
easing: 'cubicOut'
},
legend: {
top: 0,
left: 0,
itemWidth: 9,
itemHeight: 9,
itemGap: 16,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 12,
right: 16,
bottom: 24,
left: 34,
containLabel: true
top: 38,
right: 88,
bottom: 14,
left: 58
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
axisPointer: { type: 'shadow' },
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
axisPointer: {
type: 'shadow',
shadowStyle: {
color: 'rgba(58, 124, 165, 0.06)'
}
},
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [10, 12],
padding: [9, 10],
textStyle: {
color: '#475569',
color: '#334155',
fontSize: 12,
fontWeight: 700
},
@@ -117,45 +129,59 @@ const chartOptions = computed(() => ({
const amount = currency(item?.data?.amount || 0)
return `${item.marker}${item.seriesName}: ${percentValue}%(¥${amount}`
})
return [`${items[0]?.axisValue || ''}`, ...lines, `预算总额: ¥${currency(props.budget[index])}`].join('<br/>')
return [
`${items[0]?.axisValue || ''}`,
...lines,
`总占用率: ${usagePercent.value[index] || 0}%`,
`预算总额: ¥${currency(props.budget[index])}`
].join('<br/>')
}
},
xAxis: {
type: 'category',
data: props.labels,
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#64748b',
fontSize: 12,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: yAxisMax.value,
splitNumber: Math.max(1, Math.ceil(yAxisMax.value / 20)),
max: 100,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#64748b',
fontSize: 12,
fontSize: 11,
fontWeight: 700,
formatter: (value) => `${Number(value)}%`
},
splitLine: { lineStyle: { color: '#edf2f7' } }
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.72)' } }
},
yAxis: {
type: 'category',
data: props.labels,
inverse: true,
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#334155',
fontSize: 12,
fontWeight: 800
}
},
series: [
{
name: '已使用',
name: '已发生',
type: 'bar',
stack: 'budgetUsage',
data: buildSeriesData(usedPercent.value, props.used),
barWidth: 16,
barWidth: 20,
emphasis: { focus: 'series' },
label: {
show: true,
position: 'inside',
color: '#ffffff',
fontSize: 11,
fontWeight: 800,
formatter: ({ value }) => Number(value) >= 10 ? `${Number(value).toFixed(1)}%` : ''
},
itemStyle: {
color: themeColors.value.chartPrimary,
borderRadius: [4, 4, 0, 0]
borderRadius: [4, 0, 0, 4]
}
},
{
@@ -163,10 +189,20 @@ const chartOptions = computed(() => ({
type: 'bar',
stack: 'budgetUsage',
data: buildSeriesData(occupiedPercent.value, props.occupied),
barWidth: 16,
barWidth: 20,
emphasis: { focus: 'series' },
label: {
show: true,
position: 'inside',
color: '#ffffff',
fontSize: 11,
fontWeight: 800,
formatter: ({ value }) => Number(value) >= 10 ? `${Number(value).toFixed(1)}%` : ''
},
itemStyle: {
color: themeColors.value.warning,
borderRadius: [4, 4, 0, 0]
color: themeColors.value.chartAmber || themeColors.value.warning,
borderColor: '#ffffff',
borderWidth: 1
}
},
{
@@ -174,10 +210,22 @@ const chartOptions = computed(() => ({
type: 'bar',
stack: 'budgetUsage',
data: buildSeriesData(availablePercent.value, availableAmountSeries.value),
barWidth: 16,
barWidth: 20,
emphasis: { focus: 'series' },
label: {
show: true,
position: 'right',
distance: 10,
color: '#334155',
fontSize: 12,
fontWeight: 850,
formatter: ({ dataIndex }) => `${usagePercent.value[dataIndex] || 0}%`
},
itemStyle: {
color: '#e5edf3',
borderRadius: [4, 4, 0, 0]
color: '#e8eef5',
borderColor: '#ffffff',
borderWidth: 1,
borderRadius: [0, 4, 4, 0]
}
}
]
@@ -190,6 +238,6 @@ useEcharts(chartElement, chartOptions)
.budget-trend-chart {
position: relative;
width: 100%;
height: 220px;
height: 252px;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div ref="chartElement" class="digital-employee-daily-work-chart" role="img" :aria-label="ariaLabel"></div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, 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'
use([
GridComponent,
LegendComponent,
TooltipComponent,
EChartsBarChart,
EChartsLineChart,
CanvasRenderer
])
const props = defineProps({
rows: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const labels = computed(() => props.rows.map((item) => item.date))
const totals = computed(() => props.rows.map((item) => Number(item.total || 0)))
const failed = computed(() => props.rows.map((item) => Number(item.failed || 0)))
const outputs = computed(() => props.rows.map((item) => Number(item.businessOutputs || 0)))
const maxValue = computed(() => Math.max(...totals.value, ...failed.value, ...outputs.value, 1))
const axisMax = computed(() => Math.max(5, Math.ceil(maxValue.value * 1.2)))
const ariaLabel = computed(() =>
props.rows.map((item) => (
`${item.date}工作${item.total || 0}次,失败${item.failed || 0}次,产出${item.businessOutputs || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationDurationUpdate: 700,
grid: {
top: 34,
right: 18,
bottom: 24,
left: 30,
containLabel: true
},
legend: {
top: 0,
right: 4,
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255,255,255,.98)',
borderColor: 'rgba(148,163,184,.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
},
xAxis: {
type: 'category',
data: labels.value,
boundaryGap: true,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148,163,184,.26)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: axisMax.value,
splitNumber: 4,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226,232,240,.74)' } }
},
series: [
{
name: '工作次数',
type: 'bar',
data: totals.value,
barWidth: 18,
itemStyle: {
color: themeColors.value.chartPrimary,
borderRadius: [4, 4, 0, 0]
}
},
{
name: '失败次数',
type: 'bar',
data: failed.value,
barWidth: 18,
itemStyle: {
color: '#ef4444',
borderRadius: [4, 4, 0, 0]
}
},
{
name: '业务产出',
type: 'line',
data: outputs.value,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: '#0f766e'
},
itemStyle: {
color: '#ffffff',
borderColor: '#0f766e',
borderWidth: 2.5
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.digital-employee-daily-work-chart {
width: 100%;
height: 250px;
}
</style>

View File

@@ -0,0 +1,515 @@
<template>
<section class="digital-employee-dashboard">
<article class="panel dashboard-card digital-work-trend-panel">
<div class="card-head">
<div>
<h3>每日工作趋势 <i class="mdi mdi-information-outline"></i></h3>
<p class="card-subtitle">按天统计后台分析整理积累和评估任务的执行次数与业务产出</p>
</div>
<span class="dashboard-window"> {{ dashboard.windowDays }} </span>
</div>
<div v-if="loading" class="digital-dashboard-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载数字员工看板数据</span>
</div>
<div v-else-if="error" class="digital-dashboard-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<DigitalEmployeeDailyWorkChart v-else :rows="dailyRows" />
</article>
<article class="panel dashboard-card digital-work-day-panel">
<div class="card-head">
<h3>每日工作摘要 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="digital-day-list">
<div
v-for="row in dailyRows"
:key="row.date"
class="digital-day-row"
>
<span class="digital-day-date">{{ row.date }}</span>
<div class="digital-day-main">
<strong>{{ row.total }} 次工作</strong>
<small>成功 {{ row.success }}失败 {{ row.failed }}产出 {{ row.businessOutputs }} </small>
</div>
<span class="digital-day-output">{{ row.businessOutputs }}</span>
</div>
</div>
</article>
<article class="panel dashboard-card digital-category-panel">
<div class="card-head">
<h3>技能类型分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart
:items="categoryLegend"
:center-value="String(dashboard.totals.totalRuns || 0)"
center-label="工作次数"
/>
</article>
<article class="panel dashboard-card digital-task-panel">
<div class="card-head">
<h3>工作模块排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<BarChart
v-if="taskRanking.length"
:items="taskRanking"
value-prefix=""
value-suffix=""
/>
<div v-else class="digital-dashboard-empty">
<i class="mdi mdi-clipboard-check-outline"></i>
<span>当前周期暂无工作记录</span>
</div>
</article>
<article class="panel dashboard-card digital-output-panel">
<div class="card-head">
<h3>业务产出 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="digital-output-grid">
<div v-for="item in outputItems" :key="item.label" class="digital-output-item">
<span :style="{ color: item.color }"><i :class="item.icon"></i></span>
<div>
<strong>{{ item.value }}</strong>
<small>{{ item.label }}</small>
</div>
</div>
</div>
</article>
<article class="panel dashboard-card digital-recent-panel">
<div class="card-head">
<div>
<h3>最近工作记录 <i class="mdi mdi-information-outline"></i></h3>
<p class="card-subtitle">展示最近完成或正在执行的后台任务具体详情仍在数字员工工作记录中处理</p>
</div>
</div>
<div v-if="recentRuns.length" class="digital-recent-table">
<div class="digital-recent-head">
<span>时间</span>
<span>工作模块</span>
<span>技能类型</span>
<span>状态</span>
<span>摘要</span>
<span>产出</span>
</div>
<div v-for="run in recentRuns" :key="run.runId" class="digital-recent-row">
<span>{{ formatDateTime(run.startedAt) }}</span>
<strong>{{ run.taskLabel }}</strong>
<span>{{ run.category }}</span>
<span class="digital-status-pill" :class="run.statusTone">{{ run.statusLabel }}</span>
<span class="digital-recent-summary">{{ run.summary }}</span>
<span>{{ formatRunMetrics(run.metrics) }}</span>
</div>
</div>
<div v-else class="digital-dashboard-empty compact">
<i class="mdi mdi-clipboard-text-clock-outline"></i>
<span>暂无最近工作记录</span>
</div>
</article>
</section>
</template>
<script setup>
import { computed } from 'vue'
import BarChart from '../charts/BarChart.vue'
import DigitalEmployeeDailyWorkChart from '../charts/DigitalEmployeeDailyWorkChart.vue'
import DonutChart from '../charts/DonutChart.vue'
const props = defineProps({
dashboard: { type: Object, required: true },
loading: { type: Boolean, default: false },
error: { type: Object, default: () => null },
dailyRows: { type: Array, default: () => [] },
taskRanking: { type: Array, default: () => [] },
categoryRows: { type: Array, default: () => [] }
})
const errorMessage = computed(() => props.error?.message || '数字员工看板数据加载失败')
const recentRuns = computed(() => props.dashboard.recentRuns || [])
const categoryLegend = computed(() => {
const rows = props.categoryRows
.filter((item) => Number(item.value || item.count || 0) > 0)
.map((item) => ({
name: item.name,
value: Number(item.value || item.count || 0),
color: item.color,
display: `${Number(item.value || item.count || 0)}`
}))
if (rows.length) {
return rows
}
return [{ name: '暂无数据', value: 1, display: '0次', color: '#cbd5e1' }]
})
const outputItems = computed(() => {
const totals = props.dashboard.totals || {}
return [
{
label: '风险观察',
value: Number(totals.riskObservations || 0),
icon: 'mdi mdi-shield-search',
color: 'var(--theme-primary)'
},
{
label: '风险线索',
value: Number(totals.riskClues || 0),
icon: 'mdi mdi-alert-decagram-outline',
color: '#f59e0b'
},
{
label: '画像快照',
value: Number(totals.profileSnapshots || 0),
icon: 'mdi mdi-account-search-outline',
color: '#2563eb'
},
{
label: '知识文档',
value: Number(totals.knowledgeDocuments || 0),
icon: 'mdi mdi-book-open-page-variant-outline',
color: '#0f766e'
}
]
})
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 formatRunMetrics(metrics = {}) {
const rows = [
['观察', metrics.risk_observations],
['线索', metrics.risk_clues],
['快照', metrics.profile_snapshots],
['文档', metrics.knowledge_documents]
].filter(([, value]) => Number(value || 0) > 0)
if (!rows.length) {
return '0项'
}
return rows.map(([label, value]) => `${label}${Number(value || 0)}`).join(' / ')
}
</script>
<style scoped>
.digital-employee-dashboard {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 18px;
min-width: 0;
}
.dashboard-card {
min-width: 0;
padding: 18px;
border: 1px solid #edf2f7;
background: #fff;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-head h3 {
min-width: 0;
color: #1e293b;
font-size: 15px;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-head .mdi {
color: #94a3b8;
font-size: 12px;
}
.card-subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
font-weight: 550;
line-height: 1.5;
}
.dashboard-window {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.digital-work-trend-panel,
.digital-recent-panel {
grid-column: span 8;
}
.digital-work-day-panel {
grid-column: span 4;
}
.digital-category-panel,
.digital-task-panel,
.digital-output-panel {
grid-column: span 4;
}
.digital-dashboard-state,
.digital-dashboard-empty {
min-height: 220px;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.digital-dashboard-empty.compact {
min-height: 160px;
}
.digital-dashboard-state i,
.digital-dashboard-empty i {
color: #94a3b8;
font-size: 24px;
}
.digital-dashboard-state.error {
color: #b91c1c;
}
.digital-day-list {
display: grid;
gap: 9px;
}
.digital-day-row {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) 36px;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
.digital-day-row:last-child {
border-bottom: 0;
}
.digital-day-date,
.digital-day-output {
color: #64748b;
font-size: 12px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.digital-day-main {
min-width: 0;
display: grid;
gap: 3px;
}
.digital-day-main strong,
.digital-day-main small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.digital-day-main strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.digital-day-main small {
color: #94a3b8;
font-size: 11px;
font-weight: 700;
}
.digital-day-output {
text-align: right;
color: #0f766e;
}
.digital-output-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.digital-output-item {
min-width: 0;
min-height: 92px;
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.digital-output-item > span {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 4px;
background: #fff;
font-size: 18px;
flex: 0 0 auto;
}
.digital-output-item div {
min-width: 0;
}
.digital-output-item strong {
display: block;
color: #0f172a;
font-size: 22px;
font-weight: 850;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.digital-output-item small {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.digital-recent-table {
display: grid;
border: 1px solid #edf2f7;
border-radius: 4px;
overflow: hidden;
}
.digital-recent-head,
.digital-recent-row {
display: grid;
grid-template-columns: 116px 150px 84px 76px minmax(0, 1fr) 150px;
align-items: center;
gap: 10px;
}
.digital-recent-head {
min-height: 38px;
padding: 0 12px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.digital-recent-row {
min-height: 50px;
padding: 10px 12px;
border-top: 1px solid #edf2f7;
color: #475569;
font-size: 12px;
font-weight: 750;
}
.digital-recent-row strong,
.digital-recent-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.digital-recent-row strong {
color: #0f172a;
font-weight: 850;
}
.digital-recent-summary {
color: #64748b;
}
.digital-status-pill {
display: inline-flex;
width: fit-content;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: 4px;
background: #eef2f7;
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.digital-status-pill.success {
background: rgba(var(--success-rgb), .10);
color: var(--success);
}
.digital-status-pill.danger {
background: rgba(239, 68, 68, .10);
color: #dc2626;
}
.digital-status-pill.warning {
background: rgba(245, 158, 11, .12);
color: #b45309;
}
@media (max-width: 1280px) {
.digital-work-trend-panel,
.digital-recent-panel,
.digital-work-day-panel,
.digital-category-panel,
.digital-task-panel,
.digital-output-panel {
grid-column: span 12;
}
.digital-recent-head,
.digital-recent-row {
grid-template-columns: 108px 140px 76px 72px minmax(180px, 1fr) 130px;
}
}
</style>

View File

@@ -204,9 +204,10 @@ const effectItems = computed(() => {
return [
{ label: '规则命中', value: sourceDistribution.rule_center || 0 },
{ label: '图谱异常', value: sourceDistribution.financial_risk_graph || 0 },
{ label: '待复核线索', value: props.dashboard.riskClueCount || pending },
{ label: '反馈样本', value: props.dashboard.feedbackSampleCount || 0 },
{ label: '确认率', value: formatPercent(props.dashboard.confirmationRate) },
{ label: '误报率', value: formatPercent(props.dashboard.falsePositiveRate) },
{ label: '候选规则', value: props.dashboard.candidateRuleCount || 0 },
{ label: '完成率', value: formatPercent(processedRate) }
]
})

View File

@@ -281,7 +281,7 @@ const emit = defineEmits([
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder'].includes(props.activeView) && props.detailMode)
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder', 'budget'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
@@ -447,6 +447,7 @@ const draftEnd = ref(props.customRange.end)
const overviewDashboardOptions = [
{ label: '财务看板', value: 'finance' },
{ label: '风险看板', value: 'risk' },
{ label: '数字员工看板', value: 'digitalEmployee' },
{ label: '系统看板', value: 'system' }
]
const overviewDashboardValue = computed({

View File

@@ -100,6 +100,28 @@
</div>
</div>
<div
v-if="buildFieldPipelineSections(message.result).length"
class="risk-sim-field-pipeline"
>
<section
v-for="section in buildFieldPipelineSections(message.result)"
:key="section.key"
>
<header>
<span>{{ section.title }}</span>
<small>{{ section.description }}</small>
</header>
<ul>
<li v-for="field in section.rows" :key="field.key">
<strong>{{ field.label }}</strong>
<em>{{ field.source }}</em>
<b>{{ field.value }}</b>
</li>
</ul>
</section>
</div>
<div
v-if="buildRecognizedFieldRows(message.result).length"
class="risk-sim-recognized-fields"
@@ -254,6 +276,12 @@
<p>{{ boundaryDescription }}</p>
</section>
<section>
<span>测试报告</span>
<strong>{{ testReportTitle }}</strong>
<p>{{ testReportDescription }}</p>
</section>
<section>
<span>使用字段</span>
<div class="risk-sim-field-list">
@@ -311,6 +339,7 @@ import {
import {
buildDocumentBrief,
buildEvidenceItems as buildEvidenceItemsModel,
buildFieldPipelineSections as buildFieldPipelineSectionsModel,
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
buildResultFields as buildResultFieldsModel,
buildTraceItems as buildTraceItemsModel,
@@ -419,6 +448,15 @@ const lastSimulationHint = computed(() => {
? `最近一次仿真:命中${activeSimulationResult.value.severity_label}`
: '最近一次仿真:未命中风险'
})
const testReportTitle = computed(() => latestSummary.value?.test_passed ? '已确认测试通过' : '待确认测试结论')
const testReportDescription = computed(() => {
const summary = latestSummary.value
if (!summary) return '暂无测试报告。完成一次仿真后,可点击底部按钮确认测试通过。'
if (summary.report?.summary) return summary.report.summary
if (summary.sample?.summary) return `样例复核:${summary.sample.summary}`
if (summary.scenario?.summary) return `场景试运行:${summary.scenario.summary}`
return summary.test_passed ? '测试结论已保存。' : '暂无通过结论。'
})
watch(
() => props.open,
@@ -666,6 +704,10 @@ function buildRecognizedFieldRows(result) {
return buildRecognizedFieldRowsModel(result, fields.value)
}
function buildFieldPipelineSections(result) {
return buildFieldPipelineSectionsModel(result, fields.value)
}
function buildEvidenceItems(result) {
return buildEvidenceItemsModel(result, fields.value)
}

View File

@@ -32,6 +32,30 @@ export function buildRecognizedFieldRows(result, fields = []) {
}))
}
export function buildFieldPipelineSections(result, fields = []) {
const sections = [
{
key: 'ocr',
title: 'OCR 原始字段',
description: '临时附件识别得到的原始文本和结构化字段。',
rows: buildPipelineRows(result?.ocr_raw_fields, fields, { showAttachment: true })
},
{
key: 'hermes',
title: 'Hermes 规范化字段',
description: '合并测试意图和附件后,映射到规则字段本体。',
rows: buildPipelineRows(result?.hermes_normalized_fields, fields)
},
{
key: 'executor',
title: '执行器实际输入',
description: '最终交给规则执行器参与判断的字段值。',
rows: buildPipelineRows(result?.executor_input_fields, fields, { showRequired: true })
}
]
return sections.filter((section) => section.rows.length)
}
export function buildEvidenceItems(result, fields = []) {
const evidence = result?.evidence && typeof result.evidence === 'object'
? result.evidence
@@ -111,6 +135,26 @@ function formatRecognitionSource(source) {
}[String(source || '').trim()] || '未标注来源'
}
function buildPipelineRows(rows, fields, options = {}) {
return (Array.isArray(rows) ? rows : []).slice(0, 16).map((field, index) => {
const key = String(field?.key || `field-${index}`).trim()
const sourceLabel = String(field?.source_label || '').trim() || formatRecognitionSource(field?.source)
const suffixes = [
options.showAttachment ? String(field?.attachment_name || '').trim() : '',
options.showRequired && field?.required ? '判断必需' : ''
].filter(Boolean)
return {
key: `${key}-${index}`,
label: formatFieldLabel(fields.find((item) => item.key === key) || {
key,
label: field?.label
}),
source: suffixes.length ? `${sourceLabel} · ${suffixes.join(' · ')}` : sourceLabel,
value: formatDebugValue(field?.value)
}
})
}
function formatDebugValue(value) {
if (Array.isArray(value)) return value.map((item) => String(item ?? '')).filter(Boolean).join('、') || '-'
if (value && typeof value === 'object') return JSON.stringify(value)

View File

@@ -80,6 +80,64 @@
</div>
</section>
<section class="budget-report-editor-panel">
<div class="budget-report-section-head">
<strong>预算构成编辑</strong>
<span>{{ report.periodType || '预算' }} · 可直接调整</span>
</div>
<div class="budget-editor-table" role="table" aria-label="预算构成编辑表">
<div class="budget-editor-row head" role="row">
<span role="columnheader">费用类型</span>
<span role="columnheader">编制金额</span>
<span role="columnheader">提醒</span>
<span role="columnheader">告警</span>
<span role="columnheader">风险</span>
<span role="columnheader">预算说明</span>
</div>
<div
v-for="row in draftRows"
:key="row.key"
class="budget-editor-row"
role="row"
>
<strong role="cell">{{ row.name }}</strong>
<label role="cell">
<span>金额</span>
<input v-model.number="row.budgetAmount" type="number" min="0" step="1000" />
</label>
<label role="cell">
<span>提醒</span>
<input v-model.number="row.reminderThreshold" type="number" min="0" max="100" step="1" />
</label>
<label role="cell">
<span>告警</span>
<input v-model.number="row.alertThreshold" type="number" min="0" max="100" step="1" />
</label>
<label role="cell">
<span>风险</span>
<input v-model.number="row.riskThreshold" type="number" min="0" max="100" step="1" />
</label>
<textarea v-model="row.note" role="cell" rows="2" />
</div>
</div>
<footer class="budget-editor-footer">
<div>
<span>当前编制总额</span>
<strong>{{ editableTotalDisplay }}</strong>
<small>{{ draftStatusText }}</small>
</div>
<button type="button" class="budget-editor-secondary" @click="applyRecommendedBudget">
应用建议
</button>
<button type="button" class="budget-editor-primary" @click="generateBudgetDraft">
生成预算草案
</button>
</footer>
</section>
<section class="budget-report-action-panel">
<div>
<strong>编制建议</strong>
@@ -91,7 +149,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import DonutChart from '../charts/DonutChart.vue'
@@ -102,6 +160,52 @@ const props = defineProps({
}
})
const draftRows = reactive([])
const draftStatus = ref('editing')
const formatAmount = (value) =>
`¥${Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})}`
function resetDraftRows() {
draftRows.splice(
0,
draftRows.length,
...((props.report.editableDraft?.rows || props.report.items || []).map((item) => ({
key: item.key,
name: item.name,
budgetAmount: Number(item.budgetAmount ?? item.recommendedBudget ?? 0),
reminderThreshold: Number(item.reminderThreshold ?? 70),
alertThreshold: Number(item.alertThreshold ?? 80),
riskThreshold: Number(item.riskThreshold ?? 90),
note: String(item.note || item.suggestion || '')
})))
)
draftStatus.value = 'editing'
}
watch(() => props.report, resetDraftRows, { immediate: true })
const editableTotalDisplay = computed(() =>
formatAmount(draftRows.reduce((sum, item) => sum + Number(item.budgetAmount || 0), 0))
)
const draftStatusText = computed(() =>
draftStatus.value === 'generated'
? '已生成本轮预算草案,后续可提交高级财务审核'
: '调整后可生成预算草案'
)
function applyRecommendedBudget() {
resetDraftRows()
}
function generateBudgetDraft() {
draftStatus.value = 'generated'
}
const summaryCards = computed(() => [
{
label: '上季度预算',
@@ -142,6 +246,7 @@ const summaryCards = computed(() => [
.budget-report-head,
.budget-report-main,
.budget-report-detail-panel,
.budget-report-editor-panel,
.budget-report-action-panel,
.budget-report-summary-card {
border: 1px solid #dbe4ee;
@@ -280,6 +385,137 @@ const summaryCards = computed(() => [
padding: 14px;
}
.budget-report-editor-panel {
padding: 14px;
}
.budget-editor-table {
display: grid;
gap: 8px;
}
.budget-editor-row {
display: grid;
grid-template-columns: minmax(64px, .7fr) minmax(118px, .95fr) repeat(3, minmax(68px, .55fr)) minmax(220px, 1.6fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.budget-editor-row.head {
min-height: 34px;
padding: 0 8px;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.budget-editor-row:not(.head) {
padding: 8px;
border: 1px solid #edf1f6;
border-radius: 4px;
background: #fbfdff;
}
.budget-editor-row > strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.budget-editor-row label {
min-width: 0;
display: grid;
gap: 3px;
}
.budget-editor-row label span {
display: none;
}
.budget-editor-row input,
.budget-editor-row textarea {
width: 100%;
min-width: 0;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
color: #1f2937;
font-size: 12px;
font-weight: 700;
}
.budget-editor-row input {
height: 32px;
padding: 0 8px;
}
.budget-editor-row textarea {
min-height: 42px;
padding: 7px 8px;
resize: vertical;
line-height: 1.45;
}
.budget-editor-row input:focus,
.budget-editor-row textarea:focus {
border-color: rgba(58, 124, 165, .46);
outline: 3px solid var(--theme-focus-ring, rgba(58, 124, 165, .12));
}
.budget-editor-footer {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #edf1f6;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.budget-editor-footer div {
margin-right: auto;
display: grid;
gap: 2px;
}
.budget-editor-footer span,
.budget-editor-footer small {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.budget-editor-footer strong {
color: #0f172a;
font-size: 17px;
line-height: 1.2;
font-weight: 900;
}
.budget-editor-primary,
.budget-editor-secondary {
min-height: 32px;
border-radius: 4px;
padding: 0 12px;
font-size: 12px;
font-weight: 850;
}
.budget-editor-primary {
border: 0;
background: var(--theme-primary-active);
color: #fff;
}
.budget-editor-secondary {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.budget-report-expense-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -437,9 +673,30 @@ const summaryCards = computed(() => [
@media (max-width: 900px) {
.budget-report-summary-grid,
.budget-report-main,
.budget-report-expense-list {
.budget-report-expense-list,
.budget-editor-row {
grid-template-columns: 1fr;
}
.budget-editor-row.head {
display: none;
}
.budget-editor-row label span {
display: block;
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.budget-editor-footer {
align-items: stretch;
flex-direction: column;
}
.budget-editor-footer div {
margin-right: 0;
}
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -1,622 +0,0 @@
<template>
<article class="detail-card panel employee-risk-profile-card">
<div class="employee-risk-head">
<div>
<h3 class="detail-card-title-with-icon">
<i class="mdi mdi-account-search-outline"></i>
<span>风险审核画像</span>
</h3>
<p>{{ subtitle }}</p>
</div>
<span v-if="!loading && !error" :class="['profile-level-pill', levelTone]">
{{ levelLabel }}
</span>
</div>
<div v-if="loading" class="employee-risk-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在读取画像快照</span>
</div>
<div v-else-if="error" class="employee-risk-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ error }}</span>
</div>
<div v-else-if="emptyReason" class="employee-risk-state">
<i class="mdi mdi-database-search-outline"></i>
<span>{{ emptyReason }}</span>
</div>
<div v-else class="employee-risk-body">
<div class="employee-risk-summary">
<div>
<span>审核优先级</span>
<strong>{{ reviewScore }}</strong>
</div>
<div>
<span>计算窗口</span>
<strong>{{ profile?.window_days || 90 }} </strong>
</div>
<div>
<span>同组样本</span>
<strong>{{ profile?.peer_group?.sample_size || 0 }} </strong>
</div>
<div>
<span>更新时间</span>
<strong>{{ calculatedAtText }}</strong>
</div>
</div>
<div v-if="tags.length" class="employee-risk-tags">
<span>特征标签</span>
<div>
<span
v-for="tag in tags"
:key="tag.code"
:class="['employee-risk-tag', tagTone(tag)]"
:title="tag.reason"
>
{{ tag.display_label || tag.label }}
<strong>{{ tag.score }}</strong>
</span>
</div>
</div>
<div v-if="radarDimensions.length" class="employee-risk-radar">
<div class="employee-risk-radar-head">
<span>行为雷达</span>
<small>分数越高表示该行为特征越明显</small>
</div>
<div class="employee-risk-radar-layout">
<svg class="employee-risk-radar-chart" viewBox="0 0 104 104" aria-hidden="true">
<polygon
v-for="ring in radarRings"
:key="ring.scale"
class="employee-risk-radar-ring"
:points="ring.points"
/>
<line
v-for="axis in radarAxes"
:key="axis.key"
class="employee-risk-radar-axis"
x1="52"
y1="52"
:x2="axis.x"
:y2="axis.y"
/>
<polygon class="employee-risk-radar-area" :points="radarPolygonPoints" />
<circle
v-for="point in radarValuePoints"
:key="point.key"
class="employee-risk-radar-point"
:cx="point.x"
:cy="point.y"
r="2"
/>
</svg>
<ul class="employee-risk-radar-list">
<li v-for="item in radarDimensions" :key="item.code">
<span>{{ item.label }}</span>
<strong>{{ item.score }}</strong>
</li>
</ul>
</div>
</div>
<div class="employee-risk-profile-list">
<section v-for="item in profiles" :key="item.profile_type" class="employee-risk-profile">
<div class="employee-risk-profile-title">
<span>{{ item.profile_label }}</span>
<strong :class="profileLevelClass(item.level)">{{ item.score }}</strong>
</div>
<ul v-if="item.top_contributors?.length" class="employee-risk-evidence-list">
<li v-for="basis in item.top_contributors.slice(0, 3)" :key="basis.code">
<span>{{ basis.label }}</span>
<strong>{{ formatBasisValue(basis) }}</strong>
</li>
</ul>
<p v-else class="employee-risk-muted">暂无显著贡献项</p>
</section>
</div>
<div v-if="suggestions.length" class="employee-risk-suggestions">
<span>审核建议</span>
<ul>
<li v-for="item in suggestions" :key="item.type || item.message">
{{ item.message }}
<strong v-if="item.recommended_upper">建议上限 {{ item.recommended_upper }}{{ item.unit || '' }}</strong>
</li>
</ul>
</div>
</div>
</article>
</template>
<script>
import { computed } from 'vue'
export default {
name: 'EmployeeProfileRiskCard',
props: {
profile: {
type: Object,
default: null
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
}
},
setup(props) {
const profiles = computed(() => Array.isArray(props.profile?.profiles) ? props.profile.profiles : [])
const tags = computed(() => Array.isArray(props.profile?.profile_tags) ? props.profile.profile_tags.slice(0, 6) : [])
const radarDimensions = computed(() => Array.isArray(props.profile?.radar?.dimensions) ? props.profile.radar.dimensions : [])
const suggestions = computed(() => Array.isArray(props.profile?.review_suggestions) ? props.profile.review_suggestions : [])
const emptyReason = computed(() => String(props.profile?.empty_reason || '').trim())
const reviewScore = computed(() => Number(props.profile?.review_priority_score || 0))
const level = computed(() => String(props.profile?.review_priority_level || 'normal').trim())
const levelLabel = computed(() => String(props.profile?.review_priority_label || '正常').trim())
const levelTone = computed(() => profileLevelClass(level.value))
const subtitle = computed(() => {
if (props.loading) {
return '读取员工近期费用和流程质量画像。'
}
if (props.error || emptyReason.value) {
return '当前画像不可用,审批时按单据事实继续核对。'
}
const windowDays = props.profile?.window_days || 90
const sampleSize = props.profile?.peer_group?.sample_size || 0
return `${windowDays} 天窗口,同组样本 ${sampleSize} 人,用于辅助复核费用节奏和材料质量。`
})
const calculatedAtText = computed(() => formatDateTime(props.profile?.calculated_at))
const radarRings = computed(() => [0.25, 0.5, 0.75, 1].map((scale) => ({
scale,
points: radarDimensions.value.map((_, index) => radarPoint(index, radarDimensions.value.length, scale)).join(' ')
})))
const radarAxes = computed(() => radarDimensions.value.map((item, index) => ({
key: item.code,
...radarPointObject(index, radarDimensions.value.length, 1)
})))
const radarValuePoints = computed(() => radarDimensions.value.map((item, index) => ({
key: item.code,
...radarPointObject(index, radarDimensions.value.length, Number(item.score || 0) / 100)
})))
const radarPolygonPoints = computed(() => radarValuePoints.value.map((point) => `${point.x},${point.y}`).join(' '))
function formatDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return '暂无'
}
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) {
return normalized.slice(0, 16)
}
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function profileLevelClass(value) {
const normalized = String(value || '').trim()
if (normalized === 'escalation') {
return 'high'
}
if (normalized === 'review') {
return 'medium'
}
if (normalized === 'watch') {
return 'watch'
}
return 'normal'
}
function radarPoint(index, total, scale) {
const point = radarPointObject(index, total, scale)
return `${point.x},${point.y}`
}
function radarPointObject(index, total, scale) {
if (!total) {
return { x: 52, y: 52 }
}
const angle = (-90 + (360 / total) * index) * (Math.PI / 180)
const radius = 42 * Math.max(0, Math.min(1, scale))
return {
x: Number((52 + Math.cos(angle) * radius).toFixed(2)),
y: Number((52 + Math.sin(angle) * radius).toFixed(2))
}
}
function tagTone(tag) {
const polarity = String(tag?.polarity || '').trim()
if (polarity === 'positive') {
return 'positive'
}
if (Number(tag?.score || 0) >= 80 || polarity === 'risk') {
return 'risk'
}
return 'behavior'
}
function formatBasisValue(basis) {
const value = basis?.value
const unit = String(basis?.unit || '').trim()
if (value == null || value === '') {
return basis?.score != null ? `${basis.score}` : ''
}
if (unit === '占比') {
const ratio = Number(value)
return Number.isFinite(ratio) ? `${Math.round(ratio * 100)}%` : String(value)
}
return `${value}${unit && unit !== '比例' ? unit : ''}`
}
return {
calculatedAtText,
emptyReason,
formatBasisValue,
levelLabel,
levelTone,
profileLevelClass,
profiles,
radarAxes,
radarDimensions,
radarPolygonPoints,
radarRings,
radarValuePoints,
reviewScore,
subtitle,
suggestions,
tags,
tagTone
}
}
}
</script>
<style scoped>
.employee-risk-profile-card {
display: grid;
gap: 14px;
}
.employee-risk-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.employee-risk-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.profile-level-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.profile-level-pill.normal,
.employee-risk-profile-title strong.normal {
background: #ecfdf5;
color: #047857;
}
.profile-level-pill.watch,
.employee-risk-profile-title strong.watch {
background: #eff6ff;
color: #2563eb;
}
.profile-level-pill.medium,
.employee-risk-profile-title strong.medium {
background: #fff7ed;
color: #c2410c;
}
.profile-level-pill.high,
.employee-risk-profile-title strong.high {
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-state {
min-height: 78px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b;
font-size: 13px;
font-weight: 750;
}
.employee-risk-state.error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-body {
display: grid;
gap: 12px;
}
.employee-risk-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.employee-risk-summary > div {
min-width: 0;
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.employee-risk-summary span,
.employee-risk-suggestions > span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-summary strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
overflow-wrap: anywhere;
}
.employee-risk-tags {
display: grid;
gap: 8px;
}
.employee-risk-tags > span,
.employee-risk-radar-head span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-tags > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.employee-risk-tag {
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 11px;
font-weight: 850;
}
.employee-risk-tag strong {
color: inherit;
font-size: 10px;
}
.employee-risk-tag.risk {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-tag.behavior {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.employee-risk-tag.positive {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.employee-risk-radar {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.employee-risk-radar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.employee-risk-radar-head small {
color: #94a3b8;
font-size: 10px;
font-weight: 700;
}
.employee-risk-radar-layout {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.employee-risk-radar-chart {
width: 112px;
height: 112px;
}
.employee-risk-radar-ring {
fill: none;
stroke: #e2e8f0;
stroke-width: 0.75;
}
.employee-risk-radar-axis {
stroke: #e2e8f0;
stroke-width: 0.75;
}
.employee-risk-radar-area {
fill: rgba(37, 99, 235, 0.16);
stroke: #2563eb;
stroke-width: 1.8;
}
.employee-risk-radar-point {
fill: #2563eb;
}
.employee-risk-radar-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
margin: 0;
padding: 0;
list-style: none;
}
.employee-risk-radar-list li {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #475569;
font-size: 11px;
font-weight: 750;
}
.employee-risk-radar-list strong {
color: #0f172a;
font-size: 12px;
font-weight: 900;
}
.employee-risk-profile-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.employee-risk-profile {
min-width: 0;
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.employee-risk-profile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.employee-risk-profile-title strong {
min-width: 36px;
height: 22px;
display: inline-grid;
place-items: center;
border-radius: 999px;
font-size: 12px;
font-weight: 900;
}
.employee-risk-evidence-list,
.employee-risk-suggestions ul {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.employee-risk-evidence-list li,
.employee-risk-suggestions li {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.employee-risk-evidence-list strong,
.employee-risk-suggestions strong {
color: #0f172a;
white-space: nowrap;
}
.employee-risk-muted {
margin: 0;
color: #94a3b8;
font-size: 12px;
}
.employee-risk-suggestions {
display: grid;
gap: 7px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
@media (max-width: 960px) {
.employee-risk-summary,
.employee-risk-profile-list {
grid-template-columns: 1fr;
}
.employee-risk-radar-layout {
grid-template-columns: 1fr;
}
.employee-risk-radar-chart {
justify-self: center;
}
}
</style>

View File

@@ -0,0 +1,722 @@
<template>
<article :class="['detail-card panel employee-risk-profile-card', `is-${decisionTone}`]">
<div class="detail-card-head employee-risk-head">
<div class="employee-risk-title-wrap">
<h3 class="detail-card-title-with-icon">
<i class="mdi mdi-account-search-outline"></i>
<span>{{ stageTitle }}</span>
</h3>
<span :class="['employee-risk-tone-pill', decisionTone]">{{ decisionBadgeLabel }}</span>
</div>
</div>
<div class="employee-risk-body">
<section :class="['employee-risk-ai-note', decisionTone]">
<div class="employee-risk-ai-main">
<span>AI 审核建议</span>
<strong>{{ decisionTitle }}</strong>
<p>{{ decisionDescription }}</p>
</div>
<div class="employee-risk-action">
<span>建议动作</span>
<strong :class="decisionTone">{{ decisionAction }}</strong>
</div>
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
</div>
</section>
<section class="employee-risk-profile-section">
<div class="employee-risk-section-head">
<span>{{ stageBasisTitle }}</span>
<small>{{ stageBasisHint }}</small>
</div>
<div class="employee-risk-profile-list">
<section
v-for="item in compactEvidenceItems"
:key="item.code"
:class="['employee-risk-profile', item.tone]"
>
<div class="employee-risk-profile-title">
<span>{{ item.label }}</span>
<strong :class="item.tone">{{ item.status }}</strong>
</div>
<ul v-if="item.evidence.length" class="employee-risk-evidence-list">
<li v-for="basis in item.evidence" :key="basis">
{{ basis }}
</li>
</ul>
<p v-else class="employee-risk-muted">暂无显著贡献项</p>
</section>
</div>
</section>
</div>
</article>
</template>
<script>
import { computed } from 'vue'
export default {
name: 'StageRiskAdviceCard',
props: {
request: {
type: Object,
default: () => ({})
},
expenseItems: {
type: Array,
default: () => []
},
aiAdvice: {
type: Object,
default: () => ({})
},
isApplicationDocument: {
type: Boolean,
default: false
}
},
setup(props) {
const requestModel = computed(() => props.request || {})
const currentItems = computed(() => Array.isArray(props.expenseItems) ? props.expenseItems : [])
const currentRiskCards = computed(() =>
(Array.isArray(props.aiAdvice?.riskCards) ? props.aiAdvice.riskCards : [])
.filter((card) => matchesCurrentStage(card, props.isApplicationDocument))
.filter((card) => ['medium', 'high'].includes(normalizeTone(card?.tone)))
)
const highRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'high'))
const mediumRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'medium'))
const materialIssues = computed(() => props.isApplicationDocument ? [] : resolveReimbursementMaterialIssues(currentItems.value))
const sceneIssues = computed(() => resolveSceneIssues(requestModel.value, currentItems.value, props.isApplicationDocument))
const decisionTone = computed(() => {
if (highRiskCards.value.length) {
return 'high'
}
if (mediumRiskCards.value.length || materialIssues.value.length || sceneIssues.value.length) {
return 'medium'
}
return 'normal'
})
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请环节风险依据' : '报销环节风险依据')
const stageBasisHint = computed(() => (
props.isApplicationDocument
? '只展示本次申请可能影响预算和审批的风险。'
: '只展示本次报销可能影响票据、金额和付款的风险。'
))
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
const decisionAction = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).action)
const decisionBadgeLabel = computed(() => {
if (decisionTone.value === 'high') {
return '高风险'
}
if (decisionTone.value === 'medium') {
return '需关注'
}
return '可审批'
})
const decisionDescription = computed(() => {
const riskCount = currentRiskCards.value.length
if (riskCount) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。`
}
if (materialIssues.value.length || sceneIssues.value.length) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
})
const adviceItems = computed(() => {
const fromRiskCards = currentRiskCards.value
.map((card) => String(card?.suggestion || card?.risk || '').trim())
.filter(Boolean)
return uniqueTexts(fromRiskCards.length ? fromRiskCards : resolveDecision(decisionTone.value, props.isApplicationDocument).advice).slice(0, 4)
})
const compactAdviceItems = computed(() => adviceItems.value.slice(0, 2))
const stageEvidenceItems = computed(() => (
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
))
const compactEvidenceItems = computed(() => {
const abnormalItems = stageEvidenceItems.value.filter((item) => isAbnormalEvidence(item))
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
return sourceItems.slice(0, 3).map((item) => ({
...item,
evidence: item.evidence.slice(0, 2)
}))
})
function buildApplicationEvidence() {
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标/.test(cardText(card)))
return [
evidenceItem('apply_amount', '申请金额与科目', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [
`申请科目:${displayValue(requestModel.value.typeLabel || requestModel.value.sceneLabel, '待确认')}`,
`申请金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
...riskTexts(amountCards)
]),
evidenceItem('apply_budget', '预算影响', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', (
budgetCards.length ? riskTexts(budgetCards) : ['当前申请暂未命中预算余额或预算占用类中高风险。']
)),
evidenceItem('apply_scene', '申请事由与场景', sceneIssues.value.length ? '待补充' : '已说明', sceneIssues.value.length ? 'medium' : 'normal', [
`申请事由:${displayValue(requestModel.value.reason, '待补充')}`,
`申请目的地/发生地点:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`,
`申请时间:${displayValue(requestModel.value.period || requestModel.value.occurredDisplay, '待补充')}`,
...sceneIssues.value.map((item) => `当前缺少:${item}`)
]),
evidenceItem('apply_risk', '申请规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, (
currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['申请环节未命中中高风险规则。']
))
]
}
function buildReimbursementEvidence() {
const attachmentCards = currentRiskCards.value.filter((card) => /附件|票据|发票|OCR|识别|单据/.test(cardText(card)))
const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标|不一致/.test(cardText(card)))
const routeCards = currentRiskCards.value.filter((card) => /城市|行程|住宿|交通|出差|地点|日期|时间/.test(cardText(card)))
const needAttachmentItems = currentItems.value.filter((item) => !item?.isSystemGenerated)
const uploadedCount = needAttachmentItems.filter((item) => String(item?.invoiceId || '').trim()).length
return [
evidenceItem('reimburse_attachment', '票据与附件', materialIssues.value.length || attachmentCards.length ? '需核对' : '完整', materialIssues.value.length || attachmentCards.length ? highestTone(attachmentCards, 'medium') : 'normal', [
`需附件明细 ${needAttachmentItems.length} 条,已关联 ${uploadedCount} 条,未上传 ${materialIssues.value.length} 条。`,
...materialIssues.value.slice(0, 3),
...riskTexts(attachmentCards)
]),
evidenceItem('reimburse_amount', '报销金额与明细', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [
`报销金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
`费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}`,
...riskTexts(amountCards)
]),
evidenceItem('reimburse_route', '行程/时间/地点', routeCards.length || sceneIssues.value.length ? '需核对' : '已匹配', routeCards.length ? highestTone(routeCards) : sceneIssues.value.length ? 'medium' : 'normal', [
`报销事由:${displayValue(requestModel.value.reason, '待补充')}`,
`报销地点/目的地:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`,
...sceneIssues.value.map((item) => `当前缺少:${item}`),
...riskTexts(routeCards)
]),
evidenceItem('reimburse_risk', '报销规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, (
currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['报销环节未命中中高风险规则。']
))
]
}
function evidenceItem(code, label, status, tone, evidence) {
return {
code,
label,
status,
tone,
evidence: uniqueTexts(evidence).filter(Boolean).slice(0, 5)
}
}
return {
adviceItems,
compactAdviceItems,
compactEvidenceItems,
decisionBadgeLabel,
decisionTone,
decisionDescription,
decisionAction,
decisionTitle,
stageBasisHint,
stageBasisTitle,
stageEvidenceItems,
stageTitle
}
}
}
function resolveDecision(tone, isApplicationDocument) {
const subject = isApplicationDocument ? '申请' : '报销'
const map = {
normal: {
title: `当前${subject}未发现中高风险阻断项`,
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`,
advice: [`按当前${subject}信息、预算/票据结果和审批权限继续处理。`, '如审批人掌握额外业务背景,可在审批意见中补充。']
},
medium: {
title: `当前${subject}存在中风险,建议核对后处理`,
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。',
advice: ['请优先核对橙色风险项对应的业务说明、金额和材料。', '信息补齐或说明充分后,再决定通过或退回。']
},
high: {
title: `当前${subject}存在高风险,不建议直接通过`,
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。',
advice: ['请优先处理红色高风险项,核对命中规则和业务佐证。', '若属于真实业务例外,应要求申请人补充原因和证明材料。']
}
}
return map[tone] || map.normal
}
function isAbnormalEvidence(item) {
const tone = normalizeTone(item?.tone)
const status = String(item?.status || '').trim()
if (tone === 'medium' || tone === 'high') {
return true
}
return !['正常', '未命中', '已说明', '完整', '已匹配', '无异常'].includes(status)
}
function matchesCurrentStage(card, isApplicationDocument) {
const businessStage = resolveCardBusinessStage(card)
if (businessStage) {
return isApplicationDocument
? businessStage === 'expense_application'
: businessStage === 'reimbursement'
}
const text = cardText(card)
if (isApplicationDocument) {
return !/报销|附件|单据|发票|票据|OCR|识别|付款|支付/.test(text) || /申请|预算|额度|事前|预估|审批/.test(text)
}
return !/申请环节|事前申请|预算申请/.test(text)
}
function resolveCardBusinessStage(card = {}) {
const candidates = [
card.businessStage,
card.business_stage,
card.controlStage,
card.control_stage
]
for (const candidate of candidates) {
const stage = normalizeBusinessStage(candidate)
if (stage) {
return stage
}
}
return ''
}
function normalizeBusinessStage(value) {
const stage = String(value || '').trim().toLowerCase()
if ([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
].includes(stage)) {
return 'expense_application'
}
if ([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
].includes(stage)) {
return 'reimbursement'
}
return ''
}
function resolveReimbursementMaterialIssues(items) {
return items
.filter((item) => !item?.isSystemGenerated && !String(item?.invoiceId || '').trim())
.map((item) => `未上传票据:${item.name || item.category || item.desc || '未命名明细'}`)
}
function resolveSceneIssues(request, items, isApplicationDocument) {
const missing = []
if (isMissing(request.reason)) {
missing.push(isApplicationDocument ? '申请事由' : '报销事由')
}
if (isMissing(request.location) || isMissing(request.sceneTarget)) {
missing.push(isApplicationDocument ? '申请地点/目的地' : '报销地点/目的地')
}
if (isMissing(request.period) || isMissing(request.occurredDisplay)) {
missing.push(isApplicationDocument ? '申请发生时间' : '报销发生时间')
}
if (!isApplicationDocument) {
const itemMissingReasonCount = items.filter((item) => isMissing(item?.itemReason || item?.desc)).length
if (itemMissingReasonCount) {
missing.push(`${itemMissingReasonCount} 条报销明细缺少事由`)
}
}
return missing
}
function normalizeTone(value) {
const tone = String(value || '').trim().toLowerCase()
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
return 'normal'
}
function highestTone(cards, fallback = 'normal') {
if (!cards.length) return fallback
if (cards.some((card) => normalizeTone(card?.tone) === 'high')) return 'high'
if (cards.some((card) => normalizeTone(card?.tone) === 'medium')) return 'medium'
return fallback
}
function riskTexts(cards) {
return cards
.map((card) => String(card?.risk || card?.summary || card?.title || '').trim())
.filter(Boolean)
.slice(0, 4)
}
function cardText(card) {
return [
card?.label,
card?.title,
card?.risk,
card?.summary,
card?.suggestion,
...(Array.isArray(card?.ruleBasis) ? card.ruleBasis : [])
]
.map((item) => String(item || '').trim())
.join(' ')
}
function displayValue(value, fallback) {
const text = String(value || '').trim()
return isMissing(text) ? fallback : text
}
function isMissing(value) {
const text = String(value || '').trim()
return !text || ['待补充', '暂无', '无', 'null', 'undefined'].includes(text)
}
function totalItemAmount(items) {
return items.reduce((sum, item) => sum + safeNumber(item?.itemAmount), 0)
}
function safeNumber(value) {
const amount = Number(value)
return Number.isFinite(amount) ? amount : 0
}
function formatCurrency(value) {
return `${safeNumber(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
}
function uniqueTexts(values) {
return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))]
}
</script>
<style scoped>
.employee-risk-profile-card {
display: grid;
gap: 10px;
padding: 12px 14px;
}
.employee-risk-head {
margin-bottom: 0;
}
.employee-risk-title-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.detail-card h3 {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.detail-card-head h3 {
margin-bottom: 0;
}
.detail-card-title-with-icon {
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1.5;
}
.detail-card-title-with-icon i {
margin-top: 1px;
color: #334155;
font-size: 16px;
line-height: 1;
}
.employee-risk-tone-pill {
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #f8fafc;
color: #475569;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.employee-risk-tone-pill.normal {
border-color: #bbf7d0;
background: #f0fdf4;
color: #047857;
}
.employee-risk-tone-pill.medium {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-tone-pill.high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-profile-title strong.normal {
background: #ecfdf5;
color: #047857;
}
.employee-risk-profile-title strong.medium {
background: #fff7ed;
color: #c2410c;
}
.employee-risk-profile-title strong.high {
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-body {
display: grid;
gap: 10px;
}
.employee-risk-ai-note,
.employee-risk-profile {
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.employee-risk-ai-note > span,
.employee-risk-ai-main > span,
.employee-risk-section-head span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-ai-note strong,
.employee-risk-ai-main strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
overflow-wrap: anywhere;
}
.employee-risk-ai-note {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 38%);
align-items: start;
gap: 10px;
padding: 10px 12px;
background: #f8fafc;
}
.employee-risk-ai-main {
min-width: 0;
display: grid;
gap: 4px;
}
.employee-risk-ai-note.medium {
border-color: #fed7aa;
background: #fff7ed;
}
.employee-risk-ai-note.high {
border-color: #fecaca;
background: #fef2f2;
}
.employee-risk-ai-note.medium strong {
color: #c2410c;
}
.employee-risk-ai-note.high strong {
color: #b91c1c;
}
.employee-risk-ai-note p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.5;
}
.employee-risk-advice-list {
display: grid;
grid-column: 1 / -1;
gap: 4px;
margin-top: -2px;
}
.employee-risk-action {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.employee-risk-action span {
flex: 0 0 auto;
color: #64748b;
font-size: 10px;
font-weight: 800;
line-height: 1.5;
}
.employee-risk-action strong {
min-width: 0;
color: #0f172a;
font-size: 12px;
font-weight: 800;
line-height: 1.5;
}
.employee-risk-action strong.medium {
color: #c2410c;
}
.employee-risk-action strong.high {
color: #b91c1c;
}
.employee-risk-advice-list p {
margin: 0;
padding-left: 8px;
border-left: 2px solid #cbd5e1;
color: #475569;
font-size: 12px;
line-height: 1.5;
}
.employee-risk-profile-section {
display: grid;
gap: 6px;
}
.employee-risk-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.employee-risk-section-head small {
color: #94a3b8;
font-size: 10px;
font-weight: 700;
}
.employee-risk-profile-list {
display: grid;
gap: 6px;
align-items: start;
}
.employee-risk-profile {
min-width: 0;
display: grid;
grid-template-columns: 142px minmax(0, 1fr);
gap: 10px;
min-height: 0;
padding: 8px 10px;
}
.employee-risk-profile.medium {
border-color: #fed7aa;
background: #fffaf4;
}
.employee-risk-profile.high {
border-color: #fecaca;
background: #fff7f7;
}
.employee-risk-profile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 22px;
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.employee-risk-profile-title span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.employee-risk-profile-title strong {
width: 48px;
height: 20px;
flex: 0 0 48px;
display: inline-grid;
place-items: center;
border-radius: 4px;
font-size: 10px;
font-weight: 900;
white-space: nowrap;
}
.employee-risk-evidence-list {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
align-content: start;
}
.employee-risk-evidence-list li {
min-width: 0;
color: #475569;
font-size: 11px;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.employee-risk-muted {
margin: 0;
color: #94a3b8;
font-size: 11px;
}
@media (max-width: 960px) {
.employee-risk-ai-note,
.employee-risk-profile {
grid-template-columns: 1fr;
}
.employee-risk-title-wrap {
flex-wrap: wrap;
}
}
</style>

View File

@@ -91,7 +91,11 @@ export function useAppShell() {
})
const detailMode = computed(() => route.name === 'app-document-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const detailAlerts = computed(() => (
detailMode.value
? buildDetailAlerts(selectedRequest.value, { currentUser: currentUser.value })
: []
))
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))

View File

@@ -127,8 +127,12 @@ export function useChat(activeView) {
is_admin: Boolean(user.isAdmin),
name: user.name || '',
role: user.role || '',
department: user.department || user.departmentName || '',
department_name: user.departmentName || user.department || '',
position: user.position || '',
grade: user.grade || '',
employee_no: user.employeeNo || '',
manager_name: user.managerName || user.manager_name || '',
active_case_id: activeCase.value?.id || ''
}
})

View File

@@ -1,18 +1,24 @@
import { computed, onMounted, ref, watch } from 'vue'
import { fetchFinanceDashboard, fetchSystemDashboard } from '../services/analytics.js'
import {
fetchDigitalEmployeeDashboard,
fetchFinanceDashboard,
fetchSystemDashboard
} from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
import {
buildDigitalEmployeeCategoryRows,
buildDigitalEmployeeDailyRows,
buildDigitalEmployeeKpiMetrics,
buildDigitalEmployeeTaskRanking,
emptyDigitalEmployeeDashboard
} from '../views/scripts/overviewDigitalEmployeeDashboardModel.js'
import {
metricBlueprints,
systemMetricBlueprints,
trendRanges,
trendSeries,
spendByCategory as fallbackSpendByCategory,
exceptionMix as fallbackExceptionMix,
departmentRangeOptions,
bottlenecks as fallbackBottlenecks,
budgetSummary as fallbackBudgetSummary,
systemDashboardTotals as fallbackSystemDashboardTotals,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
systemLoginWave as fallbackSystemLoginWave,
@@ -30,6 +36,33 @@ import {
systemToolDetailRows as fallbackSystemToolDetailRows
} from '../data/metrics.js'
const emptyFinanceTotals = {
pendingCount: 0,
pendingAmount: 0,
avgSla: 0,
autoPassRate: 0,
riskCount: 0,
slaRate: 0
}
const emptyFinanceTrend = {
labels: [],
applications: [],
approved: [],
avgHours: []
}
const emptyFinanceDonut = [
{ name: '暂无数据', value: 0, color: '#cbd5e1' }
]
const emptyFinanceBudgetSummary = {
ratio: 0,
total: '¥0',
used: '¥0',
left: '¥0'
}
export function useOverviewView(options = {}) {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
@@ -48,23 +81,9 @@ export function useOverviewView(options = {}) {
const riskDashboardPayload = ref(null)
const riskDashboardLoading = ref(false)
const riskDashboardError = ref(null)
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: 'var(--theme-primary)' },
{ name: '研发中心', amount: 146000, color: 'var(--chart-blue)' },
{ name: '市场部', amount: 96000, color: 'var(--chart-amber)' },
{ name: '运营部', amount: 68600, color: 'var(--chart-purple)' },
{ name: '行政部', amount: 48300, color: 'var(--chart-blue)' }
]
const digitalEmployeeDashboardPayload = ref(null)
const digitalEmployeeDashboardLoading = ref(false)
const digitalEmployeeDashboardError = ref(null)
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
@@ -163,6 +182,23 @@ export function useOverviewView(options = {}) {
}
}
const loadDigitalEmployeeDashboard = async () => {
digitalEmployeeDashboardLoading.value = true
digitalEmployeeDashboardError.value = null
try {
digitalEmployeeDashboardPayload.value = await fetchDigitalEmployeeDashboard({
days: 7,
limit: 300
})
} catch (error) {
digitalEmployeeDashboardPayload.value = null
digitalEmployeeDashboardError.value = error
} finally {
digitalEmployeeDashboardLoading.value = false
}
}
const setRiskWindowDays = (value) => {
const days = Number(value || 30)
const matched = riskWindowOptions.some((item) => Number(item.value) === days)
@@ -173,6 +209,7 @@ export function useOverviewView(options = {}) {
void loadFinanceDashboard()
void loadSystemDashboard()
void loadRiskDashboard()
void loadDigitalEmployeeDashboard()
})
watch(
@@ -224,14 +261,15 @@ export function useOverviewView(options = {}) {
windowDays: activeRiskWindowDays.value,
totalObservations: 0,
pendingCount: 0,
riskClueCount: 0,
highOrAboveCount: 0,
confirmedCount: 0,
falsePositiveCount: 0,
feedbackSampleCount: 0,
totalAmount: 0,
averageScore: 0,
confirmationRate: 0,
falsePositiveRate: 0,
candidateRuleCount: 0,
levelDistribution: {},
statusDistribution: {},
signalDistribution: {},
@@ -252,29 +290,32 @@ export function useOverviewView(options = {}) {
recentHighObservations: []
}
))
const digitalEmployeeDashboard = computed(() => (
digitalEmployeeDashboardPayload.value || emptyDigitalEmployeeDashboard
))
const financeDashboardTotals = computed(() => (
financeDashboardPayload.value?.totals || demoTotals
financeDashboardPayload.value?.totals || emptyFinanceTotals
))
const financeMetricMeta = computed(() => (
financeDashboardPayload.value?.metricMeta || {}
))
const financeTrend = computed(() => (
financeDashboardPayload.value?.trend || trendSeries[activeTrendRange.value]
financeDashboardPayload.value?.trend || emptyFinanceTrend
))
const financeSpendByCategory = computed(() => (
financeDashboardPayload.value?.spendByCategory || fallbackSpendByCategory
financeDashboardPayload.value?.spendByCategory || emptyFinanceDonut
))
const financeExceptionMix = computed(() => (
financeDashboardPayload.value?.exceptionMix || fallbackExceptionMix
financeDashboardPayload.value?.exceptionMix || emptyFinanceDonut
))
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || demoDepartments
financeDashboardPayload.value?.departmentRanking || []
))
const financeBottlenecks = computed(() => (
financeDashboardPayload.value?.bottlenecks || fallbackBottlenecks
financeDashboardPayload.value?.bottlenecks || []
))
const financeBudgetSummary = computed(() => (
financeDashboardPayload.value?.budgetSummary || fallbackBudgetSummary
financeDashboardPayload.value?.budgetSummary || emptyFinanceBudgetSummary
))
const resolveSystemMetricMeta = (metric) => {
@@ -327,8 +368,8 @@ export function useOverviewView(options = {}) {
if (!financeDashboardPayload.value || !meta) {
return {
changeText: metric.change,
delta: metric.delta,
changeText: financeDashboardLoading.value ? '加载中' : '实时',
delta: financeDashboardError.value ? '真实数据加载失败' : '等待真实数据',
trend: metric.trend
}
}
@@ -432,6 +473,10 @@ export function useOverviewView(options = {}) {
}))
})
const digitalEmployeeKpiMetrics = computed(() => (
buildDigitalEmployeeKpiMetrics(digitalEmployeeDashboard.value, formatNumberCompact)
))
const activeTrend = computed(() => financeTrend.value)
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
@@ -585,6 +630,9 @@ export function useOverviewView(options = {}) {
highWidth: `${Math.max((item.highOrAbove / maxValue) * 100, item.highOrAbove ? 4 : 0)}%`
}))
})
const digitalEmployeeDailyRows = computed(() => buildDigitalEmployeeDailyRows(digitalEmployeeDashboard.value))
const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(digitalEmployeeDashboard.value))
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
function buildRiskDistributionLegend(distribution, labels, colors) {
const entries = Object.entries(distribution || {})
@@ -635,6 +683,13 @@ export function useOverviewView(options = {}) {
bottlenecks,
budgetSummary,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
digitalEmployeeDashboardError,
digitalEmployeeDashboardLoading,
digitalEmployeeDailyRows,
digitalEmployeeKpiMetrics,
digitalEmployeeTaskRanking,
exceptionMix,
financeDashboardError,
financeDashboardLoading,
@@ -688,7 +743,6 @@ export function useOverviewView(options = {}) {
systemToolRankings,
systemToolTotal,
systemTrendSeries,
trendRanges,
trendSeries
trendRanges
}
}

View File

@@ -65,6 +65,12 @@ const APPLICATION_PROGRESS_LABELS = [
'审批完成'
]
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
'创建申请',
'直属领导审批',
'审批完成'
]
function parseNumber(value) {
const nextValue = Number(value)
return Number.isFinite(nextValue) ? nextValue : 0
@@ -471,11 +477,13 @@ function resolveApplicationApproverName(claim) {
function resolveApplicationBudgetApproverName(claim) {
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
return resolveDisplayName(
claim?.budget_approver_name,
claim?.budgetApproverName,
routeEvent?.next_approver_name,
routeEvent?.nextApproverName,
routeEvent?.budget_approver_name,
routeEvent?.budgetApproverName
) || 'P8预算监控者'
) || '预算管理者'
}
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
@@ -592,19 +600,36 @@ function findLatestPaymentEvent(claim) {
)
}
function findApplicationHandoffEvent(claim) {
const handoffEvents = getRiskFlags(claim).filter((flag) => (
flag
&& typeof flag === 'object'
&& normalizeText(flag.source) === 'application_handoff'
&& normalizeText(flag.application_claim_no || flag.applicationClaimNo)
))
return getLatestEvent(handoffEvents) || handoffEvents[handoffEvents.length - 1] || null
}
function normalizeApplicationHandoffDetail(flag = {}) {
const detail = flag?.application_detail || flag?.applicationDetail || {}
return detail && typeof detail === 'object' ? detail : {}
const reviewValues = flag?.review_form_values || flag?.reviewFormValues || {}
const sceneSelection = flag?.expense_scene_selection || flag?.expenseSceneSelection || {}
return [sceneSelection, reviewValues, detail]
.filter((item) => item && typeof item === 'object')
.reduce((acc, item) => ({ ...acc, ...item }), {})
}
function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = '') {
return normalizeText(
flag?.[snakeKey]
|| (camelKey ? flag?.[camelKey] : '')
|| detail?.[snakeKey]
|| (camelKey ? detail?.[camelKey] : '')
)
}
function resolveRelatedApplicationClaimNo(flag = {}) {
const detail = normalizeApplicationHandoffDetail(flag)
return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
}
function findRelatedApplicationEvent(claim) {
const events = getRiskFlags(claim).filter((flag) => (
flag
&& typeof flag === 'object'
&& resolveRelatedApplicationClaimNo(flag)
))
return getLatestEvent(events) || events[events.length - 1] || null
}
function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
@@ -631,53 +656,57 @@ function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
}
function resolveRelatedApplicationInfo(claim, typeLabel = '') {
const handoff = findApplicationHandoffEvent(claim)
if (!handoff) {
const relatedEvent = findRelatedApplicationEvent(claim)
if (!relatedEvent) {
return null
}
const detail = normalizeApplicationHandoffDetail(handoff)
const claimNo = normalizeText(handoff.application_claim_no || handoff.applicationClaimNo)
const detail = normalizeApplicationHandoffDetail(relatedEvent)
const claimNo = resolveRelatedApplicationClaimNo(relatedEvent)
const applicationType = normalizeText(
detail.application_type
|| detail.applicationType
|| handoff.application_type
|| handoff.applicationType
|| relatedEvent.application_type
|| relatedEvent.applicationType
|| typeLabel
)
const location = normalizeText(
detail.application_location
|| detail.applicationLocation
|| detail.location
|| handoff.application_location
|| handoff.applicationLocation
|| relatedEvent.application_location
|| relatedEvent.applicationLocation
|| claim?.location
)
const reason = normalizeText(
detail.application_reason
|| detail.applicationReason
|| detail.reason
|| handoff.application_reason
|| handoff.applicationReason
|| relatedEvent.application_reason
|| relatedEvent.applicationReason
|| claim?.reason
)
const content = normalizeText(
detail.application_content
|| detail.applicationContent
|| handoff.application_content
|| handoff.applicationContent
|| relatedEvent.application_content
|| relatedEvent.applicationContent
) || [applicationType, location].filter(Boolean).join(' / ')
const rawTime = normalizeText(
detail.application_time
|| detail.applicationTime
|| detail.time
|| handoff.application_time
|| handoff.applicationTime
|| detail.application_date
|| detail.applicationDate
|| relatedEvent.application_time
|| relatedEvent.applicationTime
|| relatedEvent.application_date
|| relatedEvent.applicationDate
|| claim?.occurred_at
)
return {
id: normalizeText(handoff.application_claim_id || handoff.applicationClaimId),
id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
claimNo,
content,
reason,
@@ -685,19 +714,19 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
detail.application_days
|| detail.applicationDays
|| detail.days
|| handoff.application_days
|| handoff.applicationDays
|| relatedEvent.application_days
|| relatedEvent.applicationDays
),
location,
time: formatDate(rawTime) || rawTime,
amountLabel: resolveRelatedApplicationAmountLabel(handoff, detail, claim),
statusLabel: normalizeText(handoff.application_status_label || handoff.applicationStatusLabel),
amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
transportMode: normalizeText(
detail.application_transport_mode
|| detail.applicationTransportMode
|| detail.transport_mode
|| handoff.application_transport_mode
|| handoff.applicationTransportMode
|| relatedEvent.application_transport_mode
|| relatedEvent.applicationTransportMode
)
}
}
@@ -743,6 +772,42 @@ function findMergedApplicationBudgetApprovalEvent(claim) {
)
}
function applicationRequiresBudgetReviewStep(claim, workflowNode) {
const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
if (node.includes('预算')) {
return true
}
return getRiskFlags(claim).some((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const source = normalizeText(flag.source)
const eventType = normalizeText(flag.event_type || flag.eventType)
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
const routeDecision = flag.route_decision || flag.routeDecision || {}
if (source === 'approval_routing' && flag.requires_budget_review === true) {
return true
}
if (
routeDecision
&& typeof routeDecision === 'object'
&& routeDecision.requires_budget_review === true
) {
return true
}
return (
source === 'budget_approval'
|| eventType === 'expense_application_budget_approval'
|| previousStage.includes('预算')
|| nextStage.includes('预算')
)
})
}
function buildProgressStepMeta(time, detail = '', title = '') {
return {
time,
@@ -788,7 +853,7 @@ function buildCompletedStepMeta(claim, label) {
approvalEvent.operatorName,
stepLabel === '直属领导审批' ? claim?.manager_name : '',
stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : ''
) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算监控者' : '直属领导')
) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算管理者' : '直属领导')
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
}
@@ -899,13 +964,20 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& Boolean(findMergedApplicationBudgetApprovalEvent(claim))
)
const shouldShowApplicationBudgetStep = (
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& !hasMergedApplicationBudgetApproval
&& applicationRequiresBudgetReviewStep(claim, workflowNode)
)
const progressLabels =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交']
: hasMergedApplicationBudgetApproval
? ['创建申请', '直属领导审批', '审批完成']
: APPLICATION_PROGRESS_LABELS
: shouldShowApplicationBudgetStep
? APPLICATION_PROGRESS_LABELS
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
: REIMBURSEMENT_PROGRESS_LABELS
const currentIndex =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
@@ -1061,12 +1133,17 @@ export function mapExpenseClaimToRequest(claim) {
const expenseItems = buildExpenseItems(claim, riskSummary)
const applyDateTime = claim?.submitted_at || claim?.created_at
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
return {
id: String(claim?.claim_no || claim?.id || '').trim(),
claimNo: String(claim?.claim_no || claim?.id || '').trim(),
claimId: String(claim?.id || '').trim(),
status: String(claim?.status || '').trim(),
employeeId,
employee_id: employeeId,
profileEmployeeId: employeeId || employeeName,
person: String(claim?.employee_name || '').trim() || '待补充',
dept: String(claim?.department_name || '').trim() || '待补充',
departmentName: String(claim?.department_name || '').trim() || '待补充',
@@ -1074,6 +1151,9 @@ export function mapExpenseClaimToRequest(claim) {
employeePosition: String(claim?.employee_position || '').trim(),
employeeGrade: String(claim?.employee_grade || '').trim(),
managerName: resolveDisplayName(claim?.manager_name),
budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
entity: '',
typeCode,

View File

@@ -8,13 +8,14 @@ import {
testBootstrapDatabase,
testBootstrapRuntime
} from '../services/bootstrap.js'
import { login as loginByAccount } from '../services/auth.js'
import { setRuntimeApiBaseUrl } from '../services/api.js'
import { checkBackendHealth } from './useBackendHealth.js'
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
import { useToast } from './useToast.js'
import { fetchCurrentAuthUser, login as loginByAccount } from '../services/auth.js'
import { setRuntimeApiBaseUrl } from '../services/api.js'
import { checkBackendHealth } from './useBackendHealth.js'
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
import { useToast } from './useToast.js'
import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js'
import { normalizeAuthUserSnapshot } from '../utils/authUser.js'
import {
clearAuthSessionMetrics,
finalizeAuthSession,
@@ -140,10 +141,10 @@ function buildLegacyAdminUser(username = '') {
}
}
function resolvePlatformAdminFlag(payload, roleCodes = []) {
const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
const role = String(payload?.role || '').trim().toLowerCase()
const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
function resolvePlatformAdminFlag(payload, roleCodes = []) {
const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
const role = String(payload?.role || '').trim().toLowerCase()
const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
return (
Boolean(payload?.isAdmin)
@@ -152,46 +153,36 @@ function resolvePlatformAdminFlag(payload, roleCodes = []) {
|| role === '管理员'
|| role === '系统管理员'
|| normalizedRoleCodes.includes('admin')
)
}
function readStoredUser() {
if (typeof window === 'undefined') {
return buildAnonymousUser()
}
)
}
function normalizeStoredAuthUser(payload = {}) {
const user = normalizeAuthUserSnapshot(payload, {
defaultName: DEFAULT_USER_NAME,
defaultRole: DEFAULT_USER_ROLE
})
return {
...user,
isAdmin: resolvePlatformAdminFlag(payload, user.roleCodes)
}
}
function readStoredUser() {
if (typeof window === 'undefined') {
return buildAnonymousUser()
}
const raw = window.sessionStorage.getItem(AUTH_USER_KEY)
if (raw) {
try {
const payload = JSON.parse(raw)
if (payload && typeof payload === 'object') {
const username = String(payload.username || '').trim()
const name = String(payload.name || username || DEFAULT_USER_NAME).trim()
const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : []
return {
username,
name,
role: String(payload.role || DEFAULT_USER_ROLE),
department: String(payload.department || payload.departmentName || ''),
departmentName: String(payload.departmentName || payload.department || ''),
position: String(payload.position || ''),
grade: String(payload.grade || ''),
employeeNo: String(payload.employeeNo || payload.employee_no || ''),
managerName: String(payload.managerName || payload.manager_name || ''),
location: String(payload.location || ''),
costCenter: String(payload.costCenter || payload.cost_center || ''),
financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''),
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
roleCodes,
email: String(payload.email || ''),
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
isAdmin: resolvePlatformAdminFlag(payload, roleCodes)
}
}
} catch {
return buildLegacyAdminUser(readStoredUsername())
try {
const payload = JSON.parse(raw)
if (payload && typeof payload === 'object') {
return normalizeStoredAuthUser(payload)
}
} catch {
return buildLegacyAdminUser(readStoredUsername())
}
}
@@ -241,8 +232,18 @@ function persistAuthState(value, user = null, sessionId = '') {
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
clearAuthSessionMetrics()
}
function clearSessionTimeout() {
function persistAuthUserSnapshot(user = {}) {
if (typeof window === 'undefined') {
return
}
const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
}
function clearSessionTimeout() {
if (typeof window === 'undefined' || !sessionTimeoutHandle) {
return
}
@@ -337,10 +338,10 @@ function installSessionMonitoring() {
window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
}
function syncAuthSession(options = {}) {
const shouldNotify = Boolean(options.notify)
if (!readAuthState()) {
function syncAuthSession(options = {}) {
const shouldNotify = Boolean(options.notify)
if (!readAuthState()) {
loggedIn.value = false
currentUser.value = buildAnonymousUser()
clearSessionTimeout()
@@ -354,11 +355,31 @@ function syncAuthSession(options = {}) {
loggedIn.value = true
currentUser.value = readStoredUser()
scheduleSessionTimeout()
return true
}
function reconcileEntryRoute(router) {
scheduleSessionTimeout()
return true
}
async function refreshCurrentUserFromBackend(options = {}) {
if (!readAuthState()) {
return false
}
try {
const payload = await fetchCurrentAuthUser()
const user = normalizeStoredAuthUser(payload)
currentUser.value = user
persistAuthUserSnapshot(user)
return true
} catch (error) {
if (!options.silent) {
toast(error.message || '当前用户信息刷新失败,请重新登录后再试。')
}
console.warn('Failed to refresh current user snapshot:', error)
return false
}
}
function reconcileEntryRoute(router) {
const target = resolveEntryRoute()
const current = router.currentRoute.value
@@ -376,10 +397,13 @@ export function installSessionNavigation(router) {
}
fetchBootstrapState()
.then((state) => {
applyBootstrapState(state)
setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state))
fetchSettings()
.then((state) => {
applyBootstrapState(state)
setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state))
if (loggedIn.value) {
refreshCurrentUserFromBackend({ silent: true })
}
fetchSettings()
.then((snapshot) => {
if (snapshot?.appearanceForm?.themeSkin) {
setThemeSkin(snapshot.appearanceForm.themeSkin)
@@ -653,11 +677,11 @@ async function handleLogin(credentials) {
password: credentials.password
})
const responseUser = response?.user || buildAnonymousUser()
const responseRoleCodes = Array.isArray(responseUser.roleCodes) ? responseUser.roleCodes.filter(Boolean) : []
const user = {
...responseUser,
roleCodes: responseRoleCodes,
const responseUser = normalizeStoredAuthUser(response?.user || buildAnonymousUser())
const responseRoleCodes = responseUser.roleCodes
const user = {
...responseUser,
roleCodes: responseRoleCodes,
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
}
loggedIn.value = true
@@ -738,8 +762,9 @@ export function useSystemState() {
loginError,
loginSubmitting,
logout,
resetFromClientEnv,
resolveEntryRoute,
resetFromClientEnv,
refreshCurrentUserFromBackend,
resolveEntryRoute,
runtimeTestMessage,
runtimeTestPassed,
runtimeTesting,

View File

@@ -165,6 +165,10 @@ export function generateRiskRuleAsset(payload, options = {}) {
})
}
export function fetchRiskRuleTemplates() {
return apiRequest('/agent-assets/risk-rules/templates')
}
export function updateRiskRuleDraft(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rules/draft`, {
method: 'PATCH',
@@ -181,6 +185,28 @@ export function createRiskRuleRevision(assetId, payload, options = {}) {
})
}
export function regenerateRiskRuleAsset(assetId, payload = {}, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rules/regenerate`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options),
timeoutMs: options.timeoutMs || 60000,
timeoutMessage: '风险规则重新生成时间较长,请稍后查看最新结果。'
})
}
export function submitRiskRuleFeedback(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rules/feedback`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function fetchRiskRuleFeedback(assetId, params = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rules/feedback${buildQuery(params)}`)
}
export function fetchRiskRuleLatestTest(assetId) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/latest`)
}

View File

@@ -0,0 +1,39 @@
import { apiRequest } from './api.js'
function buildQuery(params = {}) {
const search = new URLSearchParams()
if (params.agent) {
search.set('agent', params.agent)
}
if (params.status) {
search.set('status', params.status)
}
if (params.source) {
search.set('source', params.source)
}
if (params.conversationId || params.conversation_id) {
search.set('conversation_id', params.conversationId || params.conversation_id)
}
if (params.keyword) {
search.set('keyword', params.keyword)
}
if (params.limit) {
search.set('limit', String(params.limit))
}
const query = search.toString()
return query ? `?${query}` : ''
}
export function fetchAgentTraces(params = {}) {
return apiRequest(`/agent-traces${buildQuery(params)}`)
}
export function fetchAgentTraceDetail(runId) {
return apiRequest(`/agent-traces/${encodeURIComponent(String(runId || '').trim())}`)
}
export function fetchConversationTrace(conversationId) {
return apiRequest(`/agent-traces/conversations/${encodeURIComponent(String(conversationId || '').trim())}`)
}

View File

@@ -25,6 +25,15 @@ const FINANCE_DASHBOARD_FALLBACK = {
hasRealData: false
}
const DIGITAL_EMPLOYEE_DASHBOARD_FALLBACK = {
totals: null,
dailyWork: [],
taskDistribution: [],
categoryDistribution: [],
recentRuns: [],
hasRealData: false
}
function normalizeSystemDashboardPayload(payload = {}) {
return {
...SYSTEM_DASHBOARD_FALLBACK,
@@ -62,6 +71,20 @@ function normalizeFinanceDashboardPayload(payload = {}) {
}
}
export function normalizeDigitalEmployeeDashboardPayload(payload = {}) {
return {
...DIGITAL_EMPLOYEE_DASHBOARD_FALLBACK,
windowDays: Number(payload.window_days || payload.windowDays || 7),
generatedAt: payload.generated_at || payload.generatedAt || '',
hasRealData: Boolean(payload.has_real_data ?? payload.hasRealData),
totals: payload.totals || null,
dailyWork: payload.daily_work || payload.dailyWork || [],
taskDistribution: payload.task_distribution || payload.taskDistribution || [],
categoryDistribution: payload.category_distribution || payload.categoryDistribution || [],
recentRuns: payload.recent_runs || payload.recentRuns || []
}
}
export async function fetchSystemDashboard(options = {}) {
const days = Number(options.days || 7)
const search = new URLSearchParams()
@@ -75,6 +98,21 @@ export async function fetchSystemDashboard(options = {}) {
return normalizeSystemDashboardPayload(payload)
}
export async function fetchDigitalEmployeeDashboard(options = {}) {
const days = Number(options.days || 7)
const limit = Number(options.limit || 300)
const search = new URLSearchParams()
search.set('days', String(Math.max(1, Math.min(days, 30))))
search.set('limit', String(Math.max(1, Math.min(limit, 1000))))
const payload = await apiRequest(`/analytics/digital-employee-dashboard?${search.toString()}`, {
timeoutMs: Number(options.timeoutMs || 3500),
timeoutMessage: '数字员工看板真实数据加载超时,请稍后重试。'
})
return normalizeDigitalEmployeeDashboardPayload(payload)
}
export async function fetchFinanceDashboard(options = {}) {
const search = new URLSearchParams()
search.set('range_key', String(options.rangeKey || options.range || '近10日'))

View File

@@ -1,3 +1,5 @@
import { normalizeAuthUserSnapshot } from '../utils/authUser.js'
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
@@ -43,16 +45,25 @@ function readCurrentUserHeaders() {
try {
const payload = JSON.parse(raw)
const username = String(payload?.username || '').trim()
const name = String(payload?.name || username).trim()
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
const user = normalizeAuthUserSnapshot(payload)
const username = user.username
const name = user.name || username
const roleCodes = user.roleCodes
const isAdmin = resolveStoredUserAdminFlag(payload, roleCodes)
const department = String(payload?.department || payload?.departmentName || '').trim()
const costCenter = String(payload?.costCenter || payload?.cost_center || '').trim()
const department = user.department || user.departmentName
const costCenter = user.costCenter
const position = user.position
const grade = user.grade
const employeeNo = user.employeeNo
const managerName = user.managerName
const safeUsername = pickSafeHeaderValue(username)
const safeName = pickSafeHeaderValue(name)
const safeDepartment = pickSafeHeaderValue(department)
const safeCostCenter = pickSafeHeaderValue(costCenter)
const safePosition = pickSafeHeaderValue(position)
const safeGrade = pickSafeHeaderValue(grade)
const safeEmployeeNo = pickSafeHeaderValue(employeeNo)
const safeManagerName = pickSafeHeaderValue(managerName)
if (!safeUsername && !safeName) {
return {}
@@ -79,6 +90,22 @@ function readCurrentUserHeaders() {
headers['x-auth-cost-center'] = safeCostCenter
}
if (safePosition) {
headers['x-auth-position'] = safePosition
}
if (safeGrade) {
headers['x-auth-grade'] = safeGrade
}
if (safeEmployeeNo) {
headers['x-auth-employee-no'] = safeEmployeeNo
}
if (safeManagerName) {
headers['x-auth-manager-name'] = safeManagerName
}
return headers
} catch {
return {}

View File

@@ -7,6 +7,10 @@ export function login(payload) {
})
}
export function fetchCurrentAuthUser() {
return apiRequest('/auth/me')
}
export function finishSession(sessionId, payload) {
return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, {
method: 'POST',

View File

@@ -157,6 +157,13 @@ export function submitExpenseClaim(claimId) {
})
}
export function preReviewExpenseClaim(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, {
method: 'POST',
body: JSON.stringify({})
})
}
export function returnExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/return`, {
method: 'POST',

View File

@@ -53,9 +53,13 @@ export function normalizeRiskObservationDashboard(payload = {}) {
windowDays: toNumber(payload.window_days ?? payload.windowDays, 30),
totalObservations: toNumber(payload.total_observations ?? payload.totalObservations),
pendingCount: toNumber(payload.pending_count ?? payload.pendingCount),
riskClueCount: toNumber(payload.risk_clue_count ?? payload.riskClueCount),
highOrAboveCount: toNumber(payload.high_or_above_count ?? payload.highOrAboveCount),
confirmedCount: toNumber(payload.confirmed_count ?? payload.confirmedCount),
falsePositiveCount: toNumber(payload.false_positive_count ?? payload.falsePositiveCount),
feedbackSampleCount: toNumber(
payload.feedback_sample_count ?? payload.feedbackSampleCount
),
totalAmount: toNumber(payload.total_amount ?? payload.totalAmount),
averageScore: toNumber(payload.average_score ?? payload.averageScore),
confirmationRate: toNumber(payload.confirmation_rate ?? payload.confirmationRate),

View File

@@ -151,29 +151,46 @@ export function canApproveLeaderExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canApproveBudgetExpenseApplications(user, request = null) {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
if (roleCodes.includes('executive')) {
return true
}
if (!roleCodes.includes('budget_monitor')) {
return false
}
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
return false
}
export function canApproveBudgetExpenseApplications(user, request = null) {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
if (!roleCodes.some((roleCode) => roleCode === 'budget_monitor' || roleCode === 'executive')) {
return false
}
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
return false
}
return request ? departmentIntersects(request, user) : true
}
export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
export function isCurrentRequestApplicant(request, user) {
const applicantIds = collectIdentityNames(
request?.employeeId,
request?.employee_id,
request?.profileEmployeeId,
request?.employeeNo,
request?.employee_no
)
const currentIds = collectIdentityNames(
user?.id,
user?.employeeId,
user?.employee_id,
user?.employeeNo,
user?.employee_no,
user?.username,
user?.email
)
if (applicantIds.length > 0 && identityIntersects(applicantIds, currentIds)) {
return true
}
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
request?.employee_name,
request?.profileName,
request?.applicant

View File

@@ -0,0 +1,125 @@
export const TRACE_STATUS_LABELS = {
succeeded: '成功',
failed: '失败',
blocked: '阻断',
running: '运行中'
}
export const TRACE_SOURCE_LABELS = {
user_message: '用户消息',
schedule: '定时任务',
system_event: '系统事件'
}
export function normalizeTraceListItem(item = {}) {
const runId = normalizeText(item.run_id || item.runId)
return {
runId,
conversationId: normalizeText(item.conversation_id || item.conversationId),
agent: normalizeText(item.agent) || 'orchestrator',
source: normalizeText(item.source),
sourceLabel: TRACE_SOURCE_LABELS[normalizeText(item.source)] || normalizeText(item.source) || '未知来源',
status: normalizeText(item.status) || 'unknown',
statusLabel: TRACE_STATUS_LABELS[normalizeText(item.status)] || normalizeText(item.status) || '未知',
scenario: normalizeText(item.scenario),
intent: normalizeText(item.intent),
title: normalizeText(item.title) || runId,
summary: normalizeText(item.summary),
eventCount: Number(item.event_count || item.eventCount || 0),
toolCallCount: Number(item.tool_call_count || item.toolCallCount || 0),
failedToolCallCount: Number(item.failed_tool_call_count || item.failedToolCallCount || 0),
startedAt: normalizeText(item.started_at || item.startedAt),
finishedAt: normalizeText(item.finished_at || item.finishedAt),
durationMs: Number(item.duration_ms || item.durationMs || 0)
}
}
export function normalizeTraceDetail(payload = {}) {
const run = payload.run || {}
const events = Array.isArray(payload.events)
? payload.events.map(normalizeTraceEvent)
: []
return {
runId: normalizeText(run.run_id || run.runId),
conversationId: normalizeText(payload.conversation_id || payload.conversationId),
agent: normalizeText(run.agent),
source: normalizeText(run.source),
status: normalizeText(run.status),
summary: normalizeText(run.result_summary || run.resultSummary),
errorMessage: normalizeText(run.error_message || run.errorMessage),
routeJson: run.route_json || run.routeJson || {},
ontologyJson: run.ontology_json || run.ontologyJson || {},
semanticParse: payload.semantic_parse || payload.semanticParse || run.semantic_parse || run.semanticParse || null,
toolCalls: Array.isArray(payload.tool_calls || payload.toolCalls)
? payload.tool_calls || payload.toolCalls
: [],
conversationMessages: Array.isArray(payload.conversation_messages || payload.conversationMessages)
? payload.conversation_messages || payload.conversationMessages
: [],
fallbackGenerated: Boolean(payload.fallback_generated || payload.fallbackGenerated),
events
}
}
export function normalizeTraceEvent(event = {}) {
const status = normalizeText(event.status) || 'unknown'
return {
id: normalizeText(event.id),
runId: normalizeText(event.run_id || event.runId),
sequence: Number(event.sequence || 0),
stage: normalizeText(event.stage),
eventName: normalizeText(event.event_name || event.eventName),
title: normalizeText(event.title),
summary: normalizeText(event.summary),
status,
statusLabel: TRACE_STATUS_LABELS[status] || status,
inputJson: event.input_json || event.inputJson || {},
outputJson: event.output_json || event.outputJson || {},
errorMessage: normalizeText(event.error_message || event.errorMessage),
startedAt: normalizeText(event.started_at || event.startedAt),
finishedAt: normalizeText(event.finished_at || event.finishedAt),
durationMs: Number(event.duration_ms || event.durationMs || 0)
}
}
export function resolveTraceStatusTone(status) {
const normalized = normalizeText(status)
if (normalized === 'succeeded') return 'success'
if (normalized === 'failed') return 'danger'
if (normalized === 'blocked') return 'warning'
if (normalized === 'running') return 'info'
return 'muted'
}
export function formatTraceDateTime(value) {
const normalized = normalizeText(value)
if (!normalized) return '-'
const date = new Date(normalized)
return Number.isNaN(date.getTime())
? normalized
: date.toLocaleString('zh-CN', { hour12: false })
}
export function formatTraceDuration(value) {
const durationMs = Number(value || 0)
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return '0ms'
}
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`
}
return `${(durationMs / 1000).toFixed(2)}s`
}
export function formatTraceJson(value) {
try {
return JSON.stringify(value || {}, null, 2)
} catch {
return String(value || '')
}
}
function normalizeText(value) {
return String(value || '').trim()
}

70
web/src/utils/authUser.js Normal file
View File

@@ -0,0 +1,70 @@
function pickText(payload = {}, keys = [], fallback = '') {
for (const key of keys) {
const value = String(payload?.[key] ?? '').trim()
if (value) {
return value
}
}
return String(fallback || '').trim()
}
function normalizeRoleCodes(payload = {}) {
return Array.isArray(payload.roleCodes)
? payload.roleCodes.map((item) => String(item || '').trim()).filter(Boolean)
: []
}
export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
const username = pickText(payload, ['username', 'email', 'account'])
const name = pickText(
payload,
['name', 'userName', 'user_name', 'employeeName', 'employee_name'],
username || defaults.defaultName
)
const department = pickText(payload, [
'department',
'departmentName',
'department_name',
'employeeDepartment',
'employee_department'
])
const position = pickText(payload, [
'position',
'employeePosition',
'employee_position',
'jobTitle',
'job_title',
'title'
])
const employeeNo = pickText(payload, ['employeeNo', 'employee_no'])
const costCenter = pickText(payload, ['costCenter', 'cost_center'])
const financeOwnerName = pickText(payload, ['financeOwnerName', 'finance_owner_name'])
const managerName = pickText(payload, [
'managerName',
'manager_name',
'directManagerName',
'direct_manager_name',
'leaderName',
'leader_name'
])
return {
username,
name,
role: String(payload.role || defaults.defaultRole || ''),
department,
departmentName: department,
position,
grade: pickText(payload, ['grade', 'employeeGrade', 'employee_grade']),
employeeNo,
managerName,
location: pickText(payload, ['location', 'employeeLocation', 'employee_location']),
costCenter,
financeOwnerName,
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
roleCodes: normalizeRoleCodes(payload),
email: pickText(payload, ['email'], username),
avatar: pickText(payload, ['avatar'], name.slice(0, 1).toUpperCase()),
isAdmin: Boolean(payload.isAdmin)
}
}

View File

@@ -1,3 +1,6 @@
import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js'
import { canViewRiskForContext } from './riskVisibility.js'
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
@@ -125,6 +128,39 @@ function getRiskFlags(request) {
return []
}
function resolveRiskAlert(request, context = {}) {
const flags = filterActionableRiskFlags(getRiskFlags(request))
.filter((flag) => (
context.currentUser
? canViewRiskForContext(flag, {
request,
currentUser: context.currentUser,
businessStage: context.businessStage
})
: true
))
if (!flags.length) {
return null
}
const highCount = flags.filter((flag) => normalizeRiskFlagTone(flag) === 'high').length
const mediumCount = flags.filter((flag) => normalizeRiskFlagTone(flag) === 'medium').length
if (highCount > 0) {
return {
label: `高风险 ${highCount}`,
tone: 'danger',
icon: 'mdi mdi-alert-octagon-outline'
}
}
if (mediumCount > 0) {
return {
label: `中风险 ${mediumCount}`,
tone: 'warning',
icon: 'mdi mdi-alert-circle-outline'
}
}
return null
}
function parseNonNegativeInteger(value) {
const nextValue = Number(value)
return Number.isFinite(nextValue) && nextValue > 0 ? Math.floor(nextValue) : 0
@@ -180,13 +216,18 @@ function resolveSlaReminderCount(request) {
}, 0)
}
export function buildDetailAlerts(request) {
export function buildDetailAlerts(request, context = {}) {
if (!request) {
return []
}
const alerts = []
const slaReminderCount = resolveSlaReminderCount(request)
const riskAlert = resolveRiskAlert(request, context)
if (riskAlert) {
alerts.push(riskAlert)
}
alerts.push({
label: `SLA 催单次数 ${slaReminderCount}`,

View File

@@ -20,14 +20,15 @@ export function formatDocumentListTime(value) {
const date = toDate(raw)
if (date) {
const year = String(date.getFullYear())
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
return `${year}-${month}-${day} ${hour}:${minute}`
}
return raw.replace(/^\d{4}-/, '').slice(0, 11)
return raw.slice(0, 16)
}
export function resolveDocumentSortTime(value) {

View File

@@ -0,0 +1,180 @@
const LOCATION_BANDS = {
premium: ['北京', '上海', '广州', '深圳', '杭州', '南京', '苏州', '成都', '重庆', '天津'],
remote: ['新疆', '西藏', '青海', '甘肃', '宁夏', '内蒙古', '海南', '香港', '澳门', '台湾', '海外', '国外'],
coastal: ['上海', '广州', '深圳', '厦门', '福州', '青岛', '大连', '宁波', '舟山', '海口', '三亚', '天津']
}
const TRANSPORT_PRICE_BASE = {
火车: { default: 360, premium: 520, remote: 900, coastal: 520 },
飞机: { default: 850, premium: 1100, remote: 1800, coastal: 1050 },
轮船: { default: 320, premium: 480, remote: 680, coastal: 520 }
}
function normalizeTransportMode(value = '') {
const text = String(value || '').trim()
if (/飞机|机票|航班|乘机|坐飞机/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮|坐船/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return TRANSPORT_PRICE_BASE[text] ? text : ''
}
function resolveLocationBand(location = '') {
const text = String(location || '').trim()
if (LOCATION_BANDS.remote.some((keyword) => text.includes(keyword))) return 'remote'
if (LOCATION_BANDS.premium.some((keyword) => text.includes(keyword))) return 'premium'
if (LOCATION_BANDS.coastal.some((keyword) => text.includes(keyword))) return 'coastal'
return 'default'
}
function roundToTen(value) {
return Math.round(Number(value || 0) / 10) * 10
}
function parseApplicationEstimateDate(value = '') {
const match = String(value || '').match(/(20\d{2})[年\-/.](\d{1,2})[月\-/.](\d{1,2})/)
if (!match) return ''
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const parsed = new Date(Date.UTC(year, month - 1, day))
if (
Number.isNaN(parsed.getTime()) ||
parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day
) {
return ''
}
return parsed.toISOString().slice(0, 10)
}
function resolveTicketPriceFactor(queryDate = '') {
if (!queryDate) return 1
const parsed = new Date(`${queryDate}T00:00:00.000Z`)
if (Number.isNaN(parsed.getTime())) return 1
let factor = 1
const weekday = parsed.getUTCDay()
const month = parsed.getUTCMonth() + 1
if (weekday === 1) factor += 0.04
if (weekday === 5 || weekday === 0) factor += 0.08
if ([1, 2, 7, 8, 10].includes(month)) factor += 0.06
const jitter = (parsed.getUTCFullYear() + month * 13 + parsed.getUTCDate() * 7) % 7 - 3
factor += jitter * 0.01
return Math.min(1.22, Math.max(0.88, factor))
}
function resolveMockQueryLatencyMs(queryDate = '', mode = '', locationBand = '') {
let seed = String(mode || '').length * 43 + String(locationBand || '').length * 29
if (queryDate) {
const parsed = new Date(`${queryDate}T00:00:00.000Z`)
if (!Number.isNaN(parsed.getTime())) {
seed += parsed.getUTCFullYear() + (parsed.getUTCMonth() + 1) * 17 + parsed.getUTCDate() * 31
}
}
return 360 + (seed % 420)
}
export function parseApplicationEstimateMoney(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
const normalized = String(value ?? '').replace(/,/g, '').replace(/[^\d.-]/g, '')
const amount = Number(normalized)
return Number.isFinite(amount) ? amount : 0
}
export function formatApplicationEstimateMoney(value) {
const amount = parseApplicationEstimateMoney(value)
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount)
}
export function buildMockApplicationTransportEstimate({
transportMode = '',
location = '',
travelDate = '',
time = ''
} = {}) {
const mode = normalizeTransportMode(transportMode)
if (!mode) return null
const locationBand = resolveLocationBand(location)
const queryDate = parseApplicationEstimateDate(travelDate) || parseApplicationEstimateDate(time)
const priceFactor = resolveTicketPriceFactor(queryDate)
const simulatedLatencyMs = resolveMockQueryLatencyMs(queryDate, mode, locationBand)
const priceConfig = TRANSPORT_PRICE_BASE[mode] || TRANSPORT_PRICE_BASE.火车
const oneWayAmount = priceConfig[locationBand] || priceConfig.default
const amount = roundToTen(oneWayAmount * 2 * priceFactor)
const amountDisplay = formatApplicationEstimateMoney(amount)
const bandLabel = {
premium: '一线/高频城市',
remote: '远途地区',
coastal: '沿海城市',
default: '普通城市'
}[locationBand]
const queryLabel = queryDate || '出行日期待确认'
return {
mode,
amount,
amountDisplay,
routeType: '往返',
locationBand,
queryDate,
priceFactor,
simulatedLatencyMs,
source: 'mock_ticket_price_query_v1',
confidence: 'mock',
basisText: `已查询 ${queryLabel} ${mode}参考票价,按${bandLabel}往返 ${amountDisplay}元估算(查询耗时 ${simulatedLatencyMs}ms`
}
}
export function resolveMockApplicationTransportWaitMs(estimate = null) {
const latency = Number(estimate?.simulatedLatencyMs || 0)
return Number.isFinite(latency) && latency > 0
? Math.min(320, Math.max(180, latency))
: 0
}
export async function waitForMockApplicationTransportQuote(options = {}) {
const estimate = buildMockApplicationTransportEstimate(options)
const waitMs = resolveMockApplicationTransportWaitMs(estimate)
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs))
}
return estimate
}
export function buildSystemApplicationEstimate({
transportMode = '',
location = '',
travelDate = '',
time = '',
lodgingAmount = 0,
allowanceAmount = 0
} = {}) {
const transportEstimate = buildMockApplicationTransportEstimate({
transportMode,
location,
travelDate,
time
})
const transportAmount = transportEstimate?.amount || 0
const lodging = parseApplicationEstimateMoney(lodgingAmount)
const allowance = parseApplicationEstimateMoney(allowanceAmount)
const totalAmount = transportAmount + lodging + allowance
return {
transportEstimate,
transportAmount,
lodgingAmount: lodging,
allowanceAmount: allowance,
totalAmount,
transportAmountDisplay: transportEstimate ? transportEstimate.amountDisplay : '',
lodgingAmountDisplay: formatApplicationEstimateMoney(lodging),
allowanceAmountDisplay: formatApplicationEstimateMoney(allowance),
totalAmountDisplay: formatApplicationEstimateMoney(totalAmount)
}
}

View File

@@ -79,6 +79,7 @@ export function buildExpenseApplicationOntologyContext(currentUser = {}) {
department_name: currentUser.department || currentUser.departmentName || '',
position: currentUser.position || '',
grade: currentUser.grade || '',
manager_name: currentUser.managerName || currentUser.manager_name || '',
employee_no: currentUser.employeeNo || currentUser.employee_no || ''
}
}
@@ -386,6 +387,8 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
transportMode,
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充',
position: currentUser.position || currentUser.employeePosition || '待补充',
managerName: currentUser.managerName || currentUser.manager_name || '待补充',
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
attachmentPolicy
}

View File

@@ -1,4 +1,8 @@
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
import {
buildMockApplicationTransportEstimate,
buildSystemApplicationEstimate
} from './expenseApplicationEstimate.js'
import { getTodayDateValue } from './workbenchComposerDate.js'
const APPLICATION_SESSION_TYPE = 'application'
@@ -7,7 +11,11 @@ const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'applicationType', label: '申请类型' },
{ key: 'applicant', label: '姓名', editable: false, required: false },
{ key: 'grade', label: '职级', highlight: true, editable: false, required: false },
{ key: 'department', label: '部门', editable: false, required: false },
{ key: 'position', label: '岗位', editable: false, required: false },
{ key: 'managerName', label: '直属领导', editable: false, required: false },
{ key: 'time', label: '发生时间' },
{ key: 'location', label: '地点' },
{ key: 'reason', label: '事由' },
@@ -17,13 +25,13 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
{ key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
{ key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
{ key: 'amount', label: '用户预估费用', highlight: true }
{ key: 'amount', label: '系统预估费用', highlight: true, editable: false, required: false }
]
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '车票、机票暂无实时价格接口,按真实票据实报实销'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
function compactText(value) {
return String(value || '').replace(/\s+/g, '')
@@ -114,6 +122,38 @@ function resolveCurrentUserGrade(currentUser = {}) {
).trim()
}
function resolveCurrentUserDepartment(currentUser = {}) {
return String(
currentUser.department
|| currentUser.departmentName
|| currentUser.department_name
|| ''
).trim()
}
function resolveCurrentUserPosition(currentUser = {}) {
return String(
currentUser.position
|| currentUser.employeePosition
|| currentUser.employee_position
|| currentUser.jobTitle
|| currentUser.job_title
|| ''
).trim()
}
function resolveCurrentUserManagerName(currentUser = {}) {
return String(
currentUser.managerName
|| currentUser.manager_name
|| currentUser.directManagerName
|| currentUser.direct_manager_name
|| currentUser.leaderName
|| currentUser.leader_name
|| ''
).trim()
}
function parseApplicationDaysValue(value) {
const match = String(value || '').match(/\d+/)
const days = match ? Number(match[0]) : parseChineseNumber(value)
@@ -165,10 +205,12 @@ function formatDailyPolicyMoney(value) {
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
}
function buildTransportPolicyText(transportMode) {
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
const mode = String(transportMode || '').trim()
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${mode}票据暂无实时价格接口,按真实票据实报实销`
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${estimate.basisText},报销阶段按真实票据复核`
}
function ensureApplicationPolicyFields(fields = {}) {
@@ -180,7 +222,7 @@ function ensureApplicationPolicyFields(fields = {}) {
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
}
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode)
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode, nextFields.location, null, nextFields.time)
}
if (!String(nextFields.policyEstimate || '').trim()) {
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
@@ -371,9 +413,22 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
const totalAmount = formatPolicyMoney(result?.total_amount)
const matchedCity = String(result?.matched_city || fields.location || '').trim()
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
const systemEstimate = buildSystemApplicationEstimate({
transportMode: fields.transportMode,
location: matchedCity || fields.location,
time: fields.time,
lodgingAmount: result?.hotel_amount,
allowanceAmount: result?.allowance_amount
})
const transportEstimate = systemEstimate.transportEstimate
const queryLabel = transportEstimate?.queryDate || '出行日期待确认'
const transportText = transportEstimate
? `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + `
: ''
const totalAmount = systemEstimate.totalAmountDisplay
const amount = totalAmount ? `${totalAmount}` : fields.amount
return normalizeApplicationPreview({
...preview,
@@ -382,24 +437,85 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
grade,
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: buildTransportPolicyText(fields.transportMode),
policyEstimate: `住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days},不含交通票据`,
transportPolicy: buildTransportPolicyText(fields.transportMode, matchedCity || fields.location, transportEstimate, fields.time),
policyEstimate: `${transportText}住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`,
amount,
matchedCity,
ruleName: String(result?.rule_name || '').trim(),
ruleVersion: String(result?.rule_version || '').trim(),
hotelAmount: hotelAmount ? `${hotelAmount}` : '',
allowanceAmount: allowanceAmount ? `${allowanceAmount}` : '',
transportEstimatedAmount: systemEstimate.transportAmountDisplay ? `${systemEstimate.transportAmountDisplay}` : '',
transportEstimateDate: transportEstimate?.queryDate || '',
transportQueryLatencyMs: transportEstimate?.simulatedLatencyMs ? `${transportEstimate.simulatedLatencyMs}ms` : '',
transportEstimateSource: transportEstimate?.source || '',
transportEstimateConfidence: transportEstimate?.confidence || '',
policyTotalAmount: totalAmount ? `${totalAmount}` : ''
},
policyEstimate: {
...result,
grade,
matchedCity
matchedCity,
transport_estimate: transportEstimate,
system_total_amount: systemEstimate.totalAmount
},
policyEstimateStatus: 'completed'
})
}
export function refreshApplicationPreviewTransportEstimate(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = { ...(normalized.fields || {}) }
const policyResult = normalized.policyEstimate && typeof normalized.policyEstimate === 'object'
? normalized.policyEstimate
: {}
const location = String(fields.matchedCity || policyResult.matched_city || fields.location || '').trim()
const hotelAmountSource = fields.hotelAmount || policyResult.hotel_amount || 0
const allowanceAmountSource = fields.allowanceAmount || policyResult.allowance_amount || 0
const systemEstimate = buildSystemApplicationEstimate({
transportMode: fields.transportMode,
location,
time: fields.time,
lodgingAmount: hotelAmountSource,
allowanceAmount: allowanceAmountSource
})
const transportEstimate = systemEstimate.transportEstimate
if (!transportEstimate) return normalized
const hotelAmount = formatPolicyMoney(hotelAmountSource)
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
const queryLabel = transportEstimate.queryDate || '出行日期待确认'
const nextFields = {
...fields,
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
transportEstimatedAmount: systemEstimate.transportAmountDisplay ? `${systemEstimate.transportAmountDisplay}` : '',
transportEstimateDate: transportEstimate.queryDate || '',
transportQueryLatencyMs: transportEstimate.simulatedLatencyMs ? `${transportEstimate.simulatedLatencyMs}ms` : '',
transportEstimateSource: transportEstimate.source || '',
transportEstimateConfidence: transportEstimate.confidence || ''
}
if (hasPolicyAmounts) {
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
const totalAmount = systemEstimate.totalAmountDisplay
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
nextFields.amount = totalAmount ? `${totalAmount}` : nextFields.amount
nextFields.policyTotalAmount = totalAmount ? `${totalAmount}` : ''
}
return normalizeApplicationPreview({
...normalized,
fields: nextFields,
policyEstimate: {
...policyResult,
matchedCity: location,
transport_estimate: transportEstimate,
system_total_amount: systemEstimate.totalAmount
}
})
}
export function applyApplicationPolicyEstimateError(preview = {}, error = null, currentUser = {}) {
const fields = { ...(preview?.fields || {}) }
const message = String(error?.message || error || '').trim()
@@ -408,7 +524,7 @@ export function applyApplicationPolicyEstimateError(preview = {}, error = null,
fields: {
...fields,
grade: fields.grade || resolveCurrentUserGrade(currentUser),
transportPolicy: buildTransportPolicyText(fields.transportMode),
transportPolicy: buildTransportPolicyText(fields.transportMode, fields.location, null, fields.time),
policyEstimate: message ? `规则中心暂未完成测算:${message}` : APPLICATION_POLICY_PENDING_TEXT
},
policyEstimateStatus: message ? 'failed' : 'pending'
@@ -456,7 +572,12 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
department: resolveProvidedValue(ontologyFields.department, currentFields.department)
department: resolveProvidedValue(ontologyFields.department, currentFields.department || resolveCurrentUserDepartment(currentUser)),
position: resolveProvidedValue(currentFields.position, resolveCurrentUserPosition(currentUser)),
managerName: resolveProvidedValue(
ontologyFields.managerName,
currentFields.managerName || resolveCurrentUserManagerName(currentUser)
)
}
return normalizeApplicationPreview({
@@ -511,7 +632,9 @@ export function buildLocalApplicationPreview(rawText, currentUser = {}, options
amount: resolveApplicationAmount(sourceText),
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
department: resolveCurrentUserDepartment(currentUser) || '待补充',
position: resolveCurrentUserPosition(currentUser) || '待补充',
managerName: resolveCurrentUserManagerName(currentUser) || '待补充'
}
return normalizeApplicationPreview({
@@ -534,7 +657,9 @@ export function buildApplicationTemplatePreview(currentUser = {}) {
amount: '',
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
department: resolveCurrentUserDepartment(currentUser) || '待补充',
position: resolveCurrentUserPosition(currentUser) || '待补充',
managerName: resolveCurrentUserManagerName(currentUser) || '待补充'
},
modelReviewStatus: 'template'
})

View File

@@ -6,12 +6,28 @@ const NON_RISK_SOURCES = new Set([
'approval_log',
'expense_claim_approval',
'expense_claim_finance_approval',
'payment'
'application_detail',
'application_handoff',
'application_link',
'application_submission',
'approval_routing',
'budget_approval',
'payment',
'sla_reminder',
'reminder',
'urge'
])
const NON_RISK_EVENTS = new Set([
'expense_claim_approval',
'expense_claim_finance_approval',
'expense_claim_payment_completed'
'expense_claim_payment_completed',
'expense_application_submission',
'expense_application_to_reimbursement_draft',
'expense_reimbursement_application_linked',
'expense_application_budget_approval',
'sla_reminder',
'reminder',
'urge'
])
const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none'])
const RISK_SOURCES = new Set([
@@ -85,9 +101,13 @@ export function isActionableRiskFlag(flag) {
const source = normalizeKey(flag.source)
const eventType = normalizeKey(flag.event_type || flag.eventType)
const actionability = normalizeKey(flag.actionability)
if (NON_RISK_SOURCES.has(source) || NON_RISK_EVENTS.has(eventType)) {
return false
}
if (actionability === 'system_trace') {
return false
}
const tone = normalizeRiskFlagTone(flag)
if (tone === 'high' || tone === 'medium' || tone === 'low') {

View File

@@ -0,0 +1,316 @@
import {
canApproveBudgetExpenseApplications,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isExecutiveUser,
isFinanceUser,
isPlatformAdminUser
} from './accessControl.js'
const APPLICATION_STAGE_ALIASES = new Set([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
])
const REIMBURSEMENT_STAGE_ALIASES = new Set([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
])
const SUPPORTED_DOMAINS = new Set(['budget', 'policy', 'invoice', 'trip', 'amount', 'workflow', 'profile'])
const SUPPORTED_ACTIONABILITIES = new Set([
'fixable_by_submitter',
'review_decision',
'budget_governance',
'finance_check',
'system_trace'
])
const SUPPORTED_SCOPES = new Set(['submitter', 'leader', 'budget_manager', 'finance', 'admin'])
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeKey(value) {
return normalizeText(value).toLowerCase()
}
export function normalizeRiskBusinessStage(value, fallback = 'reimbursement') {
const stage = normalizeKey(value)
if (APPLICATION_STAGE_ALIASES.has(stage)) {
return 'expense_application'
}
if (REIMBURSEMENT_STAGE_ALIASES.has(stage)) {
return 'reimbursement'
}
return fallback
}
export function resolveRiskBusinessStage(flag, fallback = 'reimbursement') {
if (!flag || typeof flag !== 'object') {
return resolveRiskTextBusinessStage(flag, fallback)
}
const explicitStage = normalizeRiskBusinessStage(
flag.business_stage || flag.businessStage || flag.control_stage || flag.controlStage,
''
)
if (explicitStage) {
return explicitStage
}
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
}
export function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
const text = normalizeText(value)
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
return 'reimbursement'
}
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
return 'expense_application'
}
return fallback
}
export function resolveRiskDomain(flag) {
if (!flag || typeof flag !== 'object') {
return inferRiskDomainFromText(flag)
}
const explicitDomain = normalizeKey(flag.risk_domain || flag.riskDomain || flag.domain)
if (SUPPORTED_DOMAINS.has(explicitDomain)) {
return explicitDomain
}
const source = normalizeKey(flag.source)
const eventType = normalizeKey(flag.event_type || flag.eventType)
if (source === 'budget_control' || eventType.includes('budget')) {
return 'budget'
}
if (source === 'attachment_analysis') {
return 'invoice'
}
if (source === 'manual_return' || source === 'approval_routing') {
return 'workflow'
}
if (source === 'financial_risk_graph') {
return 'profile'
}
return inferRiskDomainFromText(cardLikeText(flag))
}
export function resolveRiskActionability(flag, options = {}) {
const explicitActionability = normalizeKey(flag?.actionability)
if (SUPPORTED_ACTIONABILITIES.has(explicitActionability)) {
return explicitActionability
}
const domain = options.riskDomain || resolveRiskDomain(flag)
const stage = normalizeRiskBusinessStage(options.businessStage, 'reimbursement')
const source = normalizeKey(flag?.source)
if (isSystemTraceSource(source)) {
return 'system_trace'
}
if (domain === 'budget') {
return 'budget_governance'
}
if (source === 'manual_return') {
return 'fixable_by_submitter'
}
if (source === 'attachment_analysis') {
return 'fixable_by_submitter'
}
if (stage === 'expense_application') {
return 'review_decision'
}
if (['policy', 'invoice', 'trip', 'amount'].includes(domain)) {
return 'fixable_by_submitter'
}
return 'review_decision'
}
export function resolveRiskVisibilityScope(flag, options = {}) {
const explicitScope = normalizeKey(flag?.visibility_scope || flag?.visibilityScope)
if (SUPPORTED_SCOPES.has(explicitScope)) {
return explicitScope
}
const domain = options.riskDomain || resolveRiskDomain(flag)
const actionability = options.actionability || resolveRiskActionability(flag, options)
const stage = normalizeRiskBusinessStage(options.businessStage, 'reimbursement')
if (actionability === 'system_trace') {
return 'admin'
}
if (domain === 'budget' || actionability === 'budget_governance') {
return 'budget_manager'
}
if (actionability === 'fixable_by_submitter') {
return 'submitter'
}
if (stage === 'expense_application') {
return 'leader'
}
return 'finance'
}
export function isApplicationRiskStageRequest(request = {}) {
const documentType = normalizeText(
request?.documentTypeCode ||
request?.document_type_code ||
request?.documentType ||
request?.document_type
)
const claimNo = normalizeText(request?.claimNo || request?.claim_no || request?.documentNo || request?.id).toUpperCase()
const typeCode = normalizeKey(request?.typeCode || request?.expense_type)
return (
documentType === 'application' ||
documentType === 'expense_application' ||
claimNo.startsWith('AP-') ||
claimNo.startsWith('APP-') ||
typeCode === 'application' ||
typeCode.endsWith('_application')
)
}
export function buildRiskViewerContext(options = {}) {
const request = options.request || {}
const currentUser = options.currentUser || null
const isApplicationDocument = Boolean(
options.isApplicationDocument ?? isApplicationRiskStageRequest(request)
)
const businessStage = normalizeRiskBusinessStage(
options.businessStage,
isApplicationDocument ? 'expense_application' : 'reimbursement'
)
const isCurrentApplicant = Boolean(
options.isCurrentApplicant ?? isCurrentRequestApplicant(request, currentUser)
)
const isBudgetReviewer = Boolean(
options.isBudgetReviewer ?? canApproveBudgetExpenseApplications(currentUser, request)
)
const isDirectManagerReviewer = Boolean(
options.isDirectManagerReviewer ?? isCurrentDirectManagerForRequest(request, currentUser)
)
const isFinanceReviewer = Boolean(options.isFinanceReviewer ?? isFinanceUser(currentUser))
const isAdminViewer = Boolean(
options.isAdminViewer ?? (isPlatformAdminUser(currentUser) || isExecutiveUser(currentUser))
)
return {
request,
currentUser,
businessStage,
isApplicationDocument,
isCurrentApplicant,
isBudgetReviewer,
isDirectManagerReviewer,
isFinanceReviewer,
isAdminViewer,
canViewApprovalRiskAdvice: Boolean(options.canViewApprovalRiskAdvice)
}
}
export function canViewRiskForContext(flag, options = {}) {
const context = buildRiskViewerContext(options)
const stage = resolveRiskBusinessStage(flag, context.businessStage)
if (stage !== context.businessStage) {
return false
}
const riskDomain = resolveRiskDomain(flag)
const actionability = resolveRiskActionability(flag, { businessStage: stage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(flag, { businessStage: stage, riskDomain, actionability })
if (context.isAdminViewer) {
return true
}
if (actionability === 'system_trace' || visibilityScope === 'admin') {
return false
}
if (stage === 'expense_application') {
if (context.isCurrentApplicant) {
return false
}
if (riskDomain === 'budget' || actionability === 'budget_governance' || visibilityScope === 'budget_manager') {
return context.isBudgetReviewer
}
return context.isDirectManagerReviewer || context.canViewApprovalRiskAdvice || context.isBudgetReviewer
}
if (context.isCurrentApplicant) {
return actionability === 'fixable_by_submitter' || visibilityScope === 'submitter'
}
if (riskDomain === 'budget' || actionability === 'budget_governance' || visibilityScope === 'budget_manager') {
return context.isBudgetReviewer
}
if (context.isFinanceReviewer) {
return true
}
if (context.isDirectManagerReviewer || context.canViewApprovalRiskAdvice) {
return actionability !== 'finance_check'
}
return false
}
export function filterRiskCardsForVisibility(cards = [], options = {}) {
return (Array.isArray(cards) ? cards : []).filter((card) => canViewRiskForContext(card, options))
}
function inferRiskDomainFromText(value) {
const text = normalizeText(value)
if (/预算|余额|占用|超预算|budget/i.test(text)) {
return 'budget'
}
if (/城市|行程|住宿|交通|出差|地点|日期|时间|酒店|trip|travel|city|hotel|transport|period/i.test(text)) {
return 'trip'
}
if (/附件|票据|发票|OCR|识别|单据|invoice|receipt/i.test(text)) {
return 'invoice'
}
if (/金额|超标|阈值|额度|标准|amount|limit|over/i.test(text)) {
return 'amount'
}
if (/历史|画像|异常关系|profile|baseline/i.test(text)) {
return 'profile'
}
if (/审批|退回|流程|付款|routing|approval|return|payment/i.test(text)) {
return 'workflow'
}
return 'policy'
}
function isSystemTraceSource(source) {
return [
'application_detail',
'application_handoff',
'application_submission',
'approval',
'approval_log',
'approval_routing',
'budget_approval',
'expense_claim_approval',
'expense_claim_finance_approval',
'finance_approval',
'manual_approval',
'payment',
'sla_reminder',
'reminder',
'urge'
].includes(source)
}
function cardLikeText(card = {}) {
if (!card || typeof card !== 'object') {
return normalizeText(card)
}
return [
card.rule_code,
card.risk_category,
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}

View File

@@ -83,6 +83,14 @@ export const SECTION_DEFINITIONS = [
longDesc: '查看系统运行日志、结构化事件和请求追踪信息,作为系统设置下的排障与审计子项。',
actionLabel: ''
},
{
id: 'agentTraces',
label: 'Agent Trace',
title: 'Agent 链路追踪',
desc: '对话链路、工具调用与事件重放',
longDesc: '按 Run ID 还原 Orchestrator 到下游 Agent 的语义识别、路由、工具调用、会话写回和最终回复,便于线上排障和审计复盘。',
actionLabel: ''
},
{
id: 'mail',
label: '邮箱设置',
@@ -474,6 +482,7 @@ export function computeSectionStatus(state) {
normalizeValue(state.logForm.logPath)
),
systemLogs: true,
agentTraces: true,
mail: Boolean(
normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 &&

View File

@@ -0,0 +1,336 @@
<template>
<section class="agent-trace-center">
<section class="trace-filters panel">
<label class="trace-field trace-search">
<span>关键字</span>
<input
v-model="filters.keyword"
type="search"
placeholder="Run ID、会话 ID、摘要、场景"
@keydown.enter.prevent="refresh"
/>
</label>
<label class="trace-field">
<span>Agent</span>
<select v-model="filters.agent">
<option value="">全部 Agent</option>
<option value="orchestrator">Orchestrator</option>
<option value="user_agent">User Agent</option>
<option value="hermes">Hermes</option>
</select>
</label>
<label class="trace-field">
<span>状态</span>
<select v-model="filters.status">
<option value="">全部状态</option>
<option value="succeeded">成功</option>
<option value="blocked">阻断</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
</select>
</label>
<label class="trace-field">
<span>来源</span>
<select v-model="filters.source">
<option value="">全部来源</option>
<option value="user_message">用户消息</option>
<option value="schedule">定时任务</option>
<option value="system_event">系统事件</option>
</select>
</label>
</section>
<div class="trace-layout">
<section class="trace-list panel">
<div class="trace-list-head">
<div>
<strong>运行记录</strong>
<span> {{ traces.length }} </span>
</div>
<div class="trace-list-actions">
<button v-if="hasActiveFilter" class="trace-mini-action" type="button" @click="resetFilters">
清空筛选
</button>
<button class="trace-mini-action" type="button" :disabled="loading" @click="refresh">
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>{{ loading ? '刷新中' : '刷新' }}</span>
</button>
</div>
</div>
<div v-if="loading && !traces.length" class="trace-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载 Trace</strong>
</div>
<div v-else-if="errorMessage" class="trace-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>加载失败</strong>
<p>{{ errorMessage }}</p>
</div>
<div v-else-if="!traces.length" class="trace-state">
<i class="mdi mdi-timeline-question-outline"></i>
<strong>暂无 Trace 记录</strong>
<p>当前筛选条件下没有可展示的 Agent 运行链路</p>
</div>
<table v-else class="trace-table">
<thead>
<tr>
<th>开始时间</th>
<th>场景</th>
<th>状态</th>
<th>摘要</th>
<th>Run ID</th>
</tr>
</thead>
<tbody>
<tr
v-for="trace in traces"
:key="trace.runId"
:class="{ active: trace.runId === selectedRunId }"
tabindex="0"
@click="openTrace(trace.runId)"
@keydown.enter.prevent="openTrace(trace.runId)"
>
<td>{{ formatTraceDateTime(trace.startedAt) }}</td>
<td>
<strong>{{ trace.title }}</strong>
<span>{{ trace.sourceLabel }} · {{ trace.agent }}</span>
</td>
<td>
<span class="trace-status" :class="resolveTraceStatusTone(trace.status)">
{{ trace.statusLabel }}
</span>
</td>
<td>
<strong>{{ trace.summary || '暂无摘要' }}</strong>
<span>{{ trace.eventCount || trace.toolCallCount }} 个事件 · {{ trace.failedToolCallCount }} 个失败工具</span>
</td>
<td class="trace-run-id">{{ trace.runId }}</td>
</tr>
</tbody>
</table>
</section>
<section class="trace-detail panel">
<div v-if="detailLoading" class="trace-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在读取链路详情</strong>
</div>
<div v-else-if="detailError" class="trace-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>详情加载失败</strong>
<p>{{ detailError }}</p>
</div>
<div v-else-if="!detail" class="trace-state">
<i class="mdi mdi-timeline-clock-outline"></i>
<strong>选择一条运行记录</strong>
<p>点击左侧 Run ID可查看完整事件时间线输入输出和会话上下文</p>
</div>
<template v-else>
<header class="trace-detail-head">
<div>
<span class="trace-kicker">{{ detail.agent || 'agent' }}</span>
<h4>{{ detail.runId }}</h4>
<p>{{ detail.summary || detail.errorMessage || '暂无运行摘要' }}</p>
</div>
<span class="trace-status" :class="resolveTraceStatusTone(detail.status)">
{{ detail.status || 'unknown' }}
</span>
</header>
<div v-if="detail.fallbackGenerated" class="trace-inline-alert">
当前运行没有持久化 trace event已从 AgentRun语义解析和工具调用合成只读时间线
</div>
<div class="trace-metrics">
<div>
<span>会话</span>
<strong>{{ detail.conversationId || '-' }}</strong>
</div>
<div>
<span>工具调用</span>
<strong>{{ detail.toolCalls.length }}</strong>
</div>
<div>
<span>事件数</span>
<strong>{{ detail.events.length }}</strong>
</div>
</div>
<div class="trace-detail-grid">
<section class="trace-event-list">
<button
v-for="event in detail.events"
:key="event.id"
class="trace-event"
:class="{ active: selectedEvent?.id === event.id }"
type="button"
@click="selectedEventId = event.id"
>
<span class="event-index">{{ event.sequence }}</span>
<span class="event-copy">
<strong>{{ event.title || event.eventName }}</strong>
<small>{{ event.stage }} · {{ formatTraceDuration(event.durationMs) }}</small>
</span>
<span class="trace-status mini" :class="resolveTraceStatusTone(event.status)">
{{ event.statusLabel }}
</span>
</button>
</section>
<section class="trace-event-payload">
<template v-if="selectedEvent">
<div class="payload-head">
<div>
<strong>{{ selectedEvent.title }}</strong>
<span>{{ formatTraceDateTime(selectedEvent.startedAt) }}</span>
</div>
<span>{{ selectedEvent.summary || selectedEvent.eventName }}</span>
</div>
<p v-if="selectedEvent.errorMessage" class="trace-error-text">
{{ selectedEvent.errorMessage }}
</p>
<div class="payload-columns">
<div>
<h5>输入</h5>
<pre>{{ formatTraceJson(selectedEvent.inputJson) }}</pre>
</div>
<div>
<h5>输出</h5>
<pre>{{ formatTraceJson(selectedEvent.outputJson) }}</pre>
</div>
</div>
</template>
</section>
</div>
</template>
</section>
</div>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchAgentTraceDetail, fetchAgentTraces } from '../services/agentTraces.js'
import {
formatTraceDateTime,
formatTraceDuration,
formatTraceJson,
normalizeTraceDetail,
normalizeTraceListItem,
resolveTraceStatusTone
} from '../utils/agentTraceViewModel.js'
defineOptions({
name: 'AgentTraceCenterView'
})
const route = useRoute()
const router = useRouter()
const traces = ref([])
const detail = ref(null)
const selectedRunId = ref('')
const selectedEventId = ref('')
const loading = ref(false)
const detailLoading = ref(false)
const errorMessage = ref('')
const detailError = ref('')
const filters = ref({
keyword: '',
agent: '',
status: '',
source: ''
})
const hasActiveFilter = computed(() =>
Object.values(filters.value).some((value) => String(value || '').trim())
)
const selectedEvent = computed(() =>
detail.value?.events.find((event) => event.id === selectedEventId.value) || detail.value?.events[0] || null
)
async function refresh() {
loading.value = true
errorMessage.value = ''
try {
const payload = await fetchAgentTraces({
keyword: filters.value.keyword,
agent: filters.value.agent,
status: filters.value.status,
source: filters.value.source,
limit: 80
})
traces.value = Array.isArray(payload) ? payload.map(normalizeTraceListItem) : []
} catch (error) {
errorMessage.value = error?.message || 'Agent Trace 加载失败,请稍后重试。'
} finally {
loading.value = false
}
}
async function openTrace(runId) {
const normalizedRunId = String(runId || '').trim()
if (!normalizedRunId) {
return
}
selectedRunId.value = normalizedRunId
detailLoading.value = true
detailError.value = ''
router.replace({
name: 'app-settings',
query: {
...route.query,
section: 'agentTraces',
run_id: normalizedRunId
}
})
try {
detail.value = normalizeTraceDetail(await fetchAgentTraceDetail(normalizedRunId))
selectedEventId.value = detail.value.events[0]?.id || ''
} catch (error) {
detail.value = null
detailError.value = error?.message || 'Agent Trace 详情加载失败,请稍后重试。'
} finally {
detailLoading.value = false
}
}
function resetFilters() {
filters.value = {
keyword: '',
agent: '',
status: '',
source: ''
}
void refresh()
}
watch(
() => [filters.value.agent, filters.value.status, filters.value.source],
() => {
void refresh()
}
)
watch(
() => route.query.run_id,
(runId) => {
const normalizedRunId = String(runId || '').trim()
if (normalizedRunId && normalizedRunId !== selectedRunId.value) {
void openTrace(normalizedRunId)
}
}
)
onMounted(async () => {
await refresh()
const initialRunId = String(route.query.run_id || '').trim()
if (initialRunId) {
await openTrace(initialRunId)
}
})
</script>
<style scoped src="../assets/styles/views/agent-trace-center-view.css"></style>

View File

@@ -151,6 +151,8 @@
v-else-if="activeView === 'budget'"
:current-user="currentUser"
@open-assistant="openSmartEntry"
@detail-open-change="budgetDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView
@@ -220,6 +222,7 @@ const detailTopBarPayload = ref(null)
const auditDetailOpen = ref(false)
const digitalEmployeeDetailOpen = ref(false)
const receiptFolderDetailOpen = ref(false)
const budgetDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(false)
const mobileSidebarOpen = ref(false)
@@ -308,12 +311,17 @@ const DETAIL_TOPBAR_FALLBACKS = {
receiptFolder: {
title: '票据详情',
desc: '查看票据源文件、OCR 识别信息与关联状态。'
},
budget: {
title: '预算详情',
desc: '查看预算周期、费用占比、审核信息与预算明细。'
}
}
const customDetailTopBarActive = computed(() => (
(activeView.value === 'audit' && auditDetailOpen.value) ||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value) ||
(activeView.value === 'receiptFolder' && receiptFolderDetailOpen.value)
(activeView.value === 'receiptFolder' && receiptFolderDetailOpen.value) ||
(activeView.value === 'budget' && budgetDetailOpen.value)
))
const resolvedTopBarView = computed(() => (
customDetailTopBarActive.value

View File

@@ -363,6 +363,16 @@
<div v-if="selectedSkillIsRule" class="detail-action-group">
<template v-if="selectedSkillUsesJsonRisk">
<button
v-if="riskRuleHasPublishableRevision"
class="minor-action primary-action"
type="button"
:disabled="!canPublishRiskRule || detailBusy"
@click="openPublishRiskRuleDialog"
>
<i class="mdi mdi-rocket-launch-outline"></i>
<span>发布修订</span>
</button>
<button
v-if="canToggleRiskRuleEnabled"
class="minor-action enable-action"

View File

@@ -1,230 +1,337 @@
<template>
<section class="budget-center-page">
<TableLoadingState
v-if="budgetLoading"
title="预算数据同步中"
message="正在加载预算额度、使用情况与预警明细"
icon="mdi mdi-chart-donut"
floating
blocking
/>
<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>
<section class="budget-summary-grid" aria-label="预算概览">
<article
v-for="(metric, index) in budgetMetrics"
:key="metric.label"
class="budget-summary-card"
:class="metric.tone"
:style="{ '--delay': `${index * 55}ms` }"
>
<div class="budget-summary-head">
<span class="summary-icon">
<i :class="metric.icon"></i>
</span>
<span class="summary-label">{{ metric.label }}</span>
</div>
<strong class="summary-value">{{ metric.value }}</strong>
<div class="summary-comparison-row">
<span class="comparison-pill" :class="metric.yoy.tone">
<b>同比</b>
<em>{{ metric.yoy.value }}</em>
<i :class="metric.yoy.icon"></i>
</span>
<span class="comparison-pill" :class="metric.mom.tone">
<b>环比</b>
<em>{{ metric.mom.value }}</em>
<i :class="metric.mom.icon"></i>
</span>
</div>
</article>
</section>
<div class="document-toolbar budget-toolbar">
<div class="filter-set">
<label class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="budgetKeyword" type="search" placeholder="搜索预算编号、部门、编制人" />
</label>
<section class="budget-filter-bar">
<div class="budget-filter-set">
<label>
<span>预算年度</span>
<EnterpriseSelect v-model="filters.year" :options="yearOptions" />
</label>
<label>
<span>预算季度</span>
<EnterpriseSelect v-model="filters.quarter" :options="quarters" />
</label>
<label>
<span>费用类型</span>
<EnterpriseSelect v-model="filters.expenseType" :options="expenseTypes" />
</label>
<label>
<span>状态</span>
<EnterpriseSelect v-model="filters.status" :options="statuses" />
</label>
<label class="budget-select-filter">
<span>年度</span>
<EnterpriseSelect v-model="filters.year" :options="yearOptions" />
</label>
<label class="budget-select-filter">
<span>季度</span>
<EnterpriseSelect v-model="filters.quarter" :options="quarterOptions" />
</label>
<label class="budget-select-filter">
<span>状态</span>
<EnterpriseSelect v-model="filters.status" :options="statusOptions" />
</label>
</div>
<div class="document-actions">
<ElButton v-if="canEditBudget" class="budget-primary-btn" type="primary" @click="openBudgetAssistant()">
<i class="mdi mdi-pencil-outline"></i>
<span>编辑预算</span>
</ElButton>
</div>
</div>
<div class="budget-action-set">
<ElButton v-if="canEditBudget" class="budget-primary-btn" type="primary" @click="openBudgetAssistant">
<div class="table-wrap budget-table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="budgetLoading" class="table-state">
<TableLoadingState
title="预算数据同步中"
message="正在汇总部门预算、待审核草案与归档版本"
icon="mdi mdi-chart-donut"
floating
/>
</div>
<div v-else-if="budgetError" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>预算中心加载失败</strong>
<p>{{ budgetError }}</p>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc"
:icon="emptyState.icon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips"
/>
<table v-else class="budget-list-table" :class="activeBudgetScope">
<colgroup v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
<col class="col-budget-no">
<col class="col-department">
<col class="col-period">
<col class="col-money">
<col class="col-money">
<col class="col-money">
<col class="col-money">
<col class="col-rate">
<col class="col-status">
<col class="col-updated">
</colgroup>
<colgroup v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
<col class="col-budget-no">
<col class="col-department">
<col class="col-person">
<col class="col-submitted">
<col class="col-period">
<col class="col-money">
<col class="col-change">
<col class="col-score">
<col class="col-status">
<col class="col-status">
</colgroup>
<colgroup v-else>
<col class="col-budget-no">
<col class="col-department">
<col class="col-period">
<col class="col-version">
<col class="col-status">
<col class="col-money">
<col class="col-person">
<col class="col-submitted">
<col class="col-status">
</colgroup>
<thead>
<tr v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
<th>预算编号</th>
<th>部门</th>
<th>预算周期</th>
<th>年度预算</th>
<th>季度预算</th>
<th>月度预算</th>
<th>剩余可用</th>
<th>使用率</th>
<th>风险</th>
<th>更新时间</th>
</tr>
<tr v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
<th>草案编号</th>
<th>提交部门</th>
<th>编制人</th>
<th>提交时间</th>
<th>预算周期</th>
<th>申请预算</th>
<th>较上一版</th>
<th>AI 分析</th>
<th>风险</th>
<th>状态</th>
</tr>
<tr v-else>
<th>归档编号</th>
<th>部门</th>
<th>预算周期</th>
<th>版本</th>
<th>归档类型</th>
<th>原预算额</th>
<th>审核人</th>
<th>归档时间</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleBudgetRows" :key="row.id" @click="handleRowAction(row)">
<template v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td>
<td>{{ row.periodLabel }}</td>
<td>{{ row.annualAmountLabel }}</td>
<td>{{ row.quarterAmountLabel }}</td>
<td>{{ row.monthAmountLabel }}</td>
<td>{{ row.availableAmountLabel }}</td>
<td>
<div class="budget-rate">
<div><em :class="row.riskTone" :style="{ width: `${Math.min(row.usageRate, 100)}%` }"></em></div>
<span>{{ row.usageRateLabel }}</span>
</div>
</td>
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td>{{ row.updatedAt }}</td>
</template>
<template v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td>
<td>{{ row.compiler }}</td>
<td>{{ row.submittedAt }}</td>
<td>{{ row.periodLabel }}</td>
<td>{{ row.requestedAmountLabel }}</td>
<td><span class="budget-change">{{ row.changeRateLabel }}</span></td>
<td><span class="budget-score">{{ row.aiScore }}</span></td>
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
</template>
<template v-else>
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td>
<td>{{ row.periodLabel }}</td>
<td>{{ row.version }}</td>
<td>{{ row.archiveType }}</td>
<td>{{ row.quarterAmountLabel }}</td>
<td>{{ row.reviewer }}</td>
<td>{{ row.archivedAt }}</td>
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
</template>
</tr>
</tbody>
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary">{{ pageSummary }}</span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="budgetPage === 1" aria-label="上一页" @click="goToBudgetPage(budgetPage - 1)">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in budgetPageNumbers"
:key="page"
class="page-number"
:class="{ active: budgetPage === page }"
type="button"
:aria-current="budgetPage === page ? 'page' : undefined"
@click="goToBudgetPage(page)"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="budgetPage === totalBudgetPages" aria-label="下一页" @click="goToBudgetPage(budgetPage + 1)">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="budgetPageSize"
class="page-size-select"
:options="budgetPageSizeOptions"
size="small"
@change="changeBudgetPageSize"
/>
</footer>
</article>
<EnterpriseDetailPage
v-else-if="selectedBudget"
variant="budget-detail-page"
back-label="返回预算中心"
@back="backToList"
>
<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>
<strong>{{ item.value }}</strong>
<p>{{ item.desc }}</p>
</article>
</section>
<EnterpriseDetailCard
class="budget-detail-card budget-chart-card"
title="费用预算使用占比"
description="按费用类型展示已发生、已占用和剩余额度"
>
<BudgetTrendChart
:labels="selectedBudgetUsageData.labels"
:budget="selectedBudgetUsageData.budget"
:used="selectedBudgetUsageData.used"
:occupied="selectedBudgetUsageData.occupied"
:available="selectedBudgetUsageData.available"
/>
</EnterpriseDetailCard>
<EnterpriseDetailCard
class="budget-detail-card budget-status-explain-card"
title="预算状态说明"
description="说明当前预算发布状态与风险判断依据"
>
<div class="budget-status-explain-list">
<article
v-for="item in selectedBudgetStatusNotes"
:key="item.label"
class="budget-status-explain-item"
>
<span :class="['budget-status-tag', item.tone]">{{ item.value }}</span>
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.desc }}</p>
</div>
</article>
</div>
</EnterpriseDetailCard>
<EnterpriseDetailCard
class="budget-detail-card budget-category-card"
title="费用类型预算"
description="仅覆盖当前 demo 阶段预算管理费种"
>
<div class="budget-detail-table-wrap">
<table class="budget-detail-table">
<thead>
<tr>
<th>费用类型</th>
<th>预算金额</th>
<th>已发生</th>
<th>已占用</th>
<th>剩余</th>
<th>使用率</th>
<th>提醒</th>
<th>告警</th>
<th>风险</th>
</tr>
</thead>
<tbody>
<tr v-for="item in selectedBudget.categoryRows" :key="item.code">
<td><strong>{{ item.name }}</strong></td>
<td>{{ item.amountLabel }}</td>
<td>{{ item.usedLabel }}</td>
<td>{{ item.occupiedLabel }}</td>
<td>{{ item.availableLabel }}</td>
<td>{{ item.usageRateLabel }}</td>
<td><span class="budget-threshold reminder">{{ item.reminderLine }}</span></td>
<td><span class="budget-threshold alert">{{ item.alertLine }}</span></td>
<td><span class="budget-threshold risk">{{ item.riskLine }}</span></td>
</tr>
</tbody>
</table>
</div>
</EnterpriseDetailCard>
<template #actions>
<ElButton
v-if="selectedBudget.scope === BUDGET_SCOPE_REVIEW && canAuditBudgetDrafts"
class="budget-primary-btn"
type="primary"
@click="openBudgetReviewAssistant(selectedBudget)"
>
<i class="mdi mdi-clipboard-check-outline"></i>
<span>进入审核</span>
</ElButton>
<ElButton v-if="canEditBudget" class="budget-ghost-btn" @click="openBudgetAssistant()">
<i class="mdi mdi-pencil-outline"></i>
<span>编辑预算</span>
</ElButton>
<ElButton class="budget-ghost-btn">
<i class="mdi mdi-text-box-outline"></i>
<span>预算详情</span>
</ElButton>
</div>
</section>
<section class="budget-work-grid" :class="{ 'single-department': !canSwitchDepartments }">
<aside v-if="canSwitchDepartments" class="budget-department-panel">
<header>
<strong>部门切换</strong>
</header>
<ElInput
v-model="departmentKeyword"
class="department-search-input"
clearable
placeholder="搜索部门"
>
<template #prefix>
<i class="mdi mdi-magnify"></i>
</template>
</ElInput>
<nav class="department-list" aria-label="预算部门">
<ElButton
v-for="department in visibleDepartments"
:key="department.code"
class="department-switch-btn"
text
:class="{ active: department.code === activeDepartmentCode }"
@click="activeDepartmentCode = department.code"
>
<i :class="department.icon"></i>
<span>{{ department.name }}</span>
</ElButton>
</nav>
</aside>
<article class="budget-table-panel">
<header>
<strong>当前部门{{ activeDepartmentName }}</strong>
<ElInput
v-model="budgetTableKeyword"
class="budget-table-search"
clearable
placeholder="筛选预算明细"
aria-label="筛选预算明细"
>
<template #prefix>
<i class="mdi mdi-magnify"></i>
</template>
</ElInput>
</header>
<div class="budget-table-wrap">
<ElTable
:data="visibleBudgetRows"
class="budget-data-table"
border
stripe
>
<ElTableColumn prop="compiledAt" label="编制时间" min-width="150" align="center" />
<ElTableColumn prop="compiler" label="编制人" min-width="120" align="center" />
<ElTableColumn prop="reviewer" label="审核人" min-width="120" align="center" />
<ElTableColumn prop="expenseType" label="费用类型" min-width="140" align="center" />
<ElTableColumn prop="total" label="预算金额(元)" min-width="140" align="right" />
<ElTableColumn prop="used" label="已发生(元)" min-width="130" align="right" />
<ElTableColumn prop="occupied" label="已占用(元)" min-width="130" align="right" />
<ElTableColumn prop="left" label="剩余可用(元)" min-width="140" align="right" />
<ElTableColumn label="使用率" min-width="128" align="center">
<template #default="{ row }">
<div class="budget-rate">
<div><em :class="row.rateTone" :style="{ width: `${Math.min(row.rate, 100)}%` }"></em></div>
<span>{{ row.rate }}%</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="提醒阈值" min-width="112" align="center">
<template #default="{ row }">
<span class="budget-threshold-badge reminder">{{ row.reminderLine }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="告警阈值" min-width="112" align="center">
<template #default="{ row }">
<span class="budget-threshold-badge alert">{{ row.alertLine }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="风险阈值" min-width="112" align="center">
<template #default="{ row }">
<span class="budget-threshold-badge risk">{{ row.riskLine }}</span>
</template>
</ElTableColumn>
</ElTable>
</div>
<footer class="budget-table-foot">
<ElPagination
class="budget-pager"
background
layout="prev, pager, next"
:pager-count="5"
:current-page="budgetPage"
:page-size="budgetPageSize"
:total="totalBudgetRows"
@current-change="goToBudgetPage"
/>
<EnterpriseSelect
v-model="budgetPageSize"
class="budget-page-size-select"
:options="budgetPageSizeOptions"
aria-label="每页条数"
size="small"
/>
<span class="budget-page-summary">
{{ totalBudgetRows }} 当前第 {{ budgetPage }} / {{ totalBudgetPages }}
</span>
</footer>
</article>
</section>
<section class="budget-bottom-grid">
<article class="budget-chart-panel">
<header class="budget-card-head">
<strong>费用预算使用占比</strong>
<div class="budget-chart-legend">
<span><i class="legend-line used"></i>已使用</span>
<span><i class="legend-line occupied"></i>已占用</span>
<span><i class="legend-line available"></i>剩余可用</span>
</div>
</header>
<BudgetTrendChart
:labels="budgetUsageData.labels"
:budget="budgetUsageData.budget"
:used="budgetUsageData.used"
:occupied="budgetUsageData.occupied"
:available="budgetUsageData.available"
/>
</article>
<article class="budget-alert-panel">
<header class="budget-card-head">
<strong>预算预警</strong>
<ElButton v-if="warnings.length" class="budget-link-btn" text>查看全部</ElButton>
</header>
<div v-if="warnings.length" class="budget-alert-list">
<div v-for="alert in warnings" :key="alert.id" class="budget-alert-row">
<i :class="alert.tone"></i>
<strong>{{ alert.title }}</strong>
<span>{{ alert.desc }}</span>
<time v-if="alert.date">{{ alert.date }}</time>
</div>
</div>
<div v-else class="budget-alert-empty">
<span class="budget-alert-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<strong>暂无预算预警</strong>
<p>当前范围内预算使用率未达到预警线</p>
</div>
</article>
</section>
</template>
</EnterpriseDetailPage>
</section>
</template>
<script src="./scripts/BudgetCenterView.js"></script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
<style scoped src="../assets/styles/views/budget-center-view.css"></style>

View File

@@ -85,6 +85,9 @@
:selected-status="selectedStatus"
:selected-status-label="selectedStatusLabel"
:status-options="statusOptions"
:selected-skill-category="selectedSkillCategory"
:selected-skill-category-label="selectedSkillCategoryLabel"
:skill-category-options="skillCategoryOptions"
:selected-enabled-state="selectedEnabledState"
:selected-enabled-label="selectedEnabledLabel"
:enabled-state-options="enabledStateOptions"
@@ -212,6 +215,7 @@ watch(
)
const keyword = ref('')
const selectedStatus = ref('')
const selectedSkillCategory = ref('')
const selectedEnabledState = ref('')
const selectedExecutionMode = ref('')
const activeFilterPopover = ref('')
@@ -235,10 +239,17 @@ const scheduleEditorBusy = computed(() => actionState.value === 'save-digital-sc
const statusOptions = STATUS_OPTIONS
const enabledStateOptions = ENABLED_STATE_OPTIONS
const executionModeOptions = DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS
const skillCategoryOptions = [
{ value: '', label: '全部技能类型' },
...DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS.map((value) => ({ value, label: value }))
]
const selectedStatusLabel = computed(() =>
statusOptions.find((item) => item.value === selectedStatus.value)?.label || '全部状态'
)
const selectedSkillCategoryLabel = computed(() =>
skillCategoryOptions.find((item) => item.value === selectedSkillCategory.value)?.label || '全部技能类型'
)
const selectedEnabledLabel = computed(() =>
enabledStateOptions.find((item) => item.value === selectedEnabledState.value)?.label || '全部启动状态'
)
@@ -250,6 +261,9 @@ const activeFilterTokens = computed(() => {
if (selectedStatus.value) {
tokens.push(`资产状态:${selectedStatusLabel.value}`)
}
if (selectedSkillCategory.value) {
tokens.push(`技能类型:${selectedSkillCategoryLabel.value}`)
}
if (selectedEnabledState.value) {
tokens.push(`启动状态:${selectedEnabledLabel.value}`)
}
@@ -272,6 +286,7 @@ const visibleEmployees = computed(() => {
keyword: keyword.value,
selectedEnabledState: selectedEnabledState.value,
selectedExecutionMode: selectedExecutionMode.value,
selectedSkillCategory: selectedSkillCategory.value,
selectedStatus: selectedStatus.value
})
})
@@ -288,6 +303,9 @@ function selectFilter(type, value) {
if (type === 'status') {
selectedStatus.value = value
}
if (type === 'skillCategory') {
selectedSkillCategory.value = value
}
if (type === 'enabled') {
selectedEnabledState.value = value
}
@@ -300,6 +318,7 @@ function selectFilter(type, value) {
function resetFilters() {
keyword.value = ''
selectedStatus.value = ''
selectedSkillCategory.value = ''
selectedEnabledState.value = ''
selectedExecutionMode.value = ''
closeFilterPopover()

View File

@@ -29,10 +29,16 @@
<p>{{ hermesRun.result_summary || '暂无运行摘要。' }}</p>
<p v-if="hermesRun.status === 'running'" class="hero-hint">运行中每 5 秒自动刷新一次详情</p>
</div>
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
<div class="hero-actions">
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
<button class="refresh-btn" type="button" @click="openAgentTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
</div>
</article>
<article
@@ -438,6 +444,14 @@ async function loadDetail(options = {}) {
function backToLogs() {
router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
}
function openAgentTraceCenter() {
const runId = String(hermesRun.value?.run_id || '').trim()
if (!runId) {
return
}
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
}
watch(
() => [route.params.logKind, route.params.logId],

View File

@@ -137,6 +137,16 @@
@update:window-days="setRiskWindowDays"
/>
<DigitalEmployeeDashboard
v-else-if="activeDashboard === 'digitalEmployee'"
:dashboard="digitalEmployeeDashboard"
:loading="digitalEmployeeDashboardLoading"
:error="digitalEmployeeDashboardError"
:daily-rows="digitalEmployeeDailyRows"
:task-ranking="digitalEmployeeTaskRanking"
:category-rows="digitalEmployeeCategoryRows"
/>
<template v-else>
<div class="system-observability-grid">
<article class="panel dashboard-card system-agent-ratio-panel">
@@ -295,6 +305,7 @@ 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 DigitalEmployeeDashboard from '../components/dashboard/DigitalEmployeeDashboard.vue'
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
@@ -318,6 +329,13 @@ const {
bottlenecks,
budgetSummary,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
digitalEmployeeDashboardError,
digitalEmployeeDashboardLoading,
digitalEmployeeDailyRows,
digitalEmployeeKpiMetrics,
digitalEmployeeTaskRanking,
kpiMetrics,
rankedDepartments,
riskDashboard,
@@ -350,15 +368,15 @@ const {
const activeDashboard = computed(() => {
if (props.dashboard === 'system') return 'system'
if (props.dashboard === 'risk') return 'risk'
if (props.dashboard === 'digitalEmployee') return 'digitalEmployee'
return 'finance'
})
const activeKpiMetrics = computed(() => (
activeDashboard.value === 'system'
? systemKpiMetrics.value
: activeDashboard.value === 'risk'
? riskKpiMetrics.value
: kpiMetrics.value
))
const activeKpiMetrics = computed(() => {
if (activeDashboard.value === 'system') return systemKpiMetrics.value
if (activeDashboard.value === 'digitalEmployee') return digitalEmployeeKpiMetrics.value
if (activeDashboard.value === 'risk') return riskKpiMetrics.value
return kpiMetrics.value
})
</script>
<style scoped src="../assets/styles/views/overview-view.css"></style>

View File

@@ -146,28 +146,113 @@
loading-icon="mdi mdi-receipt-text-outline"
@back="backToList"
>
<template #main>
<EnterpriseDetailCard class="receipt-basic-panel" title="票据关键字段">
<template #actions>
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
</button>
</template>
<section class="receipt-detail-toolbar panel">
<div class="receipt-detail-title">
<strong>票据详情</strong>
<span>{{ receiptDetailTitle }}</span>
<p>查看识别结果校验状态关联单据与处理记录</p>
</div>
<div class="receipt-key-grid">
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
<span>{{ field.label }}</span>
<input
:value="field.value"
type="text"
:placeholder="field.placeholder"
@input="updateReceiptField(field, $event.target.value)"
<div class="receipt-toolbar-actions">
<button class="minor-action" type="button" @click="reloadCurrentReceipt">
<i class="mdi mdi-refresh"></i>
<span>重新读取</span>
</button>
<button
class="minor-action"
type="button"
:disabled="selectedReceipt?.status === 'linked'"
@click="openAssociateDialogForCurrentReceipt"
>
<i class="mdi mdi-link-variant-plus"></i>
<span>关联单据</span>
</button>
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
</button>
</div>
</section>
<section class="receipt-dashboard">
<EnterpriseDetailCard class="receipt-preview-panel receipt-dashboard-preview" title="票据预览">
<div class="receipt-preview-frame">
<div class="receipt-preview-box">
<img
v-if="previewKind === 'image' && previewObjectUrl"
:src="previewObjectUrl"
:style="{ transform: previewTransform }"
alt="票据预览"
/>
</label>
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
<div v-else class="preview-empty">
<i class="mdi mdi-file-eye-outline"></i>
<strong>当前文件暂不支持内嵌预览</strong>
<p>请确认源文件是否支持预览或重新上传清晰图片/PDF</p>
</div>
</div>
</div>
<div class="receipt-other-info">
<footer class="receipt-preview-tools" aria-label="票据预览工具">
<span class="preview-page">{{ previewPageLabel }}</span>
<div class="preview-tool-group">
<button type="button" :disabled="previewZoom <= 0.6" aria-label="缩小预览" @click="adjustPreviewZoom(-0.1)">
<i class="mdi mdi-minus"></i>
</button>
<strong>{{ Math.round(previewZoom * 100) }}%</strong>
<button type="button" :disabled="previewZoom >= 1.8" aria-label="放大预览" @click="adjustPreviewZoom(0.1)">
<i class="mdi mdi-plus"></i>
</button>
</div>
<div class="preview-tool-group">
<button type="button" aria-label="重置预览" @click="resetPreviewView">
<i class="mdi mdi-refresh"></i>
</button>
<button type="button" aria-label="旋转预览" @click="rotatePreview">
<i class="mdi mdi-rotate-right"></i>
</button>
</div>
</footer>
</EnterpriseDetailCard>
<div class="receipt-dashboard-side">
<EnterpriseDetailCard class="receipt-basic-panel" title="基础信息">
<template #actions>
<span class="receipt-card-count">{{ keyReceiptFields.length }} 项可编辑</span>
</template>
<div class="receipt-key-grid">
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
<span>{{ field.label }}</span>
<input
:value="field.value"
type="text"
:placeholder="field.placeholder"
@input="updateReceiptField(field, $event.target.value)"
/>
</label>
</div>
<div class="receipt-static-grid">
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</EnterpriseDetailCard>
<EnterpriseDetailCard class="receipt-ocr-panel" title="OCR识别结果">
<div v-if="ocrPreviewFields.length" class="receipt-ocr-grid">
<label v-for="field in ocrPreviewFields" :key="field.key || field.label" class="receipt-ocr-field">
<span>{{ field.label || field.key }}</span>
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
</label>
</div>
<div v-else class="receipt-field-empty">
<i class="mdi mdi-information-outline"></i>
<span>暂无可展示的 OCR 识别字段</span>
</div>
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse">
<ElCollapseItem name="other">
<template #title>
@@ -193,30 +278,50 @@
</label>
</div>
</div>
<div v-else class="receipt-field-empty">
<i class="mdi mdi-information-outline"></i>
<span>暂无其他可编辑信息</span>
</div>
</ElCollapseItem>
</ElCollapse>
</div>
</EnterpriseDetailCard>
</template>
</EnterpriseDetailCard>
<template #side>
<EnterpriseDetailCard class="receipt-preview-panel" title="原始文件">
<div class="receipt-preview-box">
<img v-if="previewKind === 'image' && previewObjectUrl" :src="previewObjectUrl" alt="票据预览" />
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
<div v-else class="preview-empty">
<i class="mdi mdi-file-eye-outline"></i>
<strong>当前文件暂不支持内嵌预览</strong>
<p>请确认源文件是否支持预览或重新上传清晰图片/PDF</p>
<EnterpriseDetailCard class="receipt-status-panel" title="处理状态">
<div class="receipt-status-grid">
<div v-for="item in receiptStatusItems" :key="item.label" class="receipt-status-item">
<span>{{ item.label }}</span>
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
</div>
</div>
</div>
</EnterpriseDetailCard>
</template>
</EnterpriseDetailCard>
</div>
<div class="receipt-dashboard-bottom">
<EnterpriseDetailCard class="receipt-info-panel" title="关联单据信息">
<div class="receipt-data-list">
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</EnterpriseDetailCard>
<EnterpriseDetailCard class="receipt-log-panel" title="处理记录 / 操作日志">
<ol class="receipt-log-list">
<li v-for="item in operationLogs" :key="`${item.time}-${item.label}`">
<span>{{ item.time }}</span>
<strong>{{ item.operator }}</strong>
<p>{{ item.label }}</p>
</li>
</ol>
</EnterpriseDetailCard>
<EnterpriseDetailCard class="receipt-info-panel" title="归档信息">
<div class="receipt-data-list">
<div v-for="item in archiveInfoItems" :key="item.label" class="receipt-data-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</EnterpriseDetailCard>
</div>
</section>
<template #actions>
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
@@ -304,6 +409,7 @@ import {
fetchReceiptFolderItems,
updateReceiptFolderItem
} from '../services/receiptFolder.js'
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
const NEW_CLAIM_VALUE = '__new_claim__'
@@ -425,6 +531,26 @@ const {
syncEditableFieldsToTopLevel,
updateReceiptField
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
const {
adjustPreviewZoom,
archiveInfoItems,
basicInfoItems,
linkedClaimItems,
ocrPreviewFields,
operationLogs,
previewPageLabel,
previewTransform,
previewZoom,
receiptStatusItems,
resetPreviewView,
rotatePreview
} = createReceiptDetailDashboardModel({
detailForm,
editableOtherFields,
formatDateTime,
formatScore,
selectedReceipt
})
const receiptDetailTopBarPayload = computed(() => (
detailMode.value
? {
@@ -516,6 +642,7 @@ function fillDetailForm(detail) {
? detail.fields.map((field) => ({ ...field }))
: []
expandedFieldPanels.value = []
resetPreviewView()
ensureEditableReceiptFields()
syncEditableFieldsToTopLevel()
}
@@ -542,6 +669,11 @@ function backToList() {
revokePreviewUrl()
}
async function reloadCurrentReceipt() {
if (!selectedReceipt.value?.id || detailLoading.value) return
await openDetail(selectedReceipt.value)
}
async function saveDetail() {
if (!selectedReceipt.value?.id || savingDetail.value) return
savingDetail.value = true
@@ -575,6 +707,15 @@ async function openAssociateDialog() {
await loadDraftClaims()
}
async function openAssociateDialogForCurrentReceipt() {
if (!selectedReceipt.value?.id || selectedReceipt.value.status === 'linked') return
selectedReceiptIds.value = [selectedReceipt.value.id]
targetDraftId.value = NEW_CLAIM_VALUE
associateStep.value = 2
associateDialogOpen.value = true
await loadDraftClaims()
}
function closeAssociateDialog() {
if (associateBusy.value) return
associateDialogOpen.value = false

View File

@@ -43,7 +43,10 @@
</div>
</header>
<div class="settings-content" :class="{ 'settings-content-fill': activeSection === 'systemLogs' }">
<div
class="settings-content"
:class="{ 'settings-content-fill': ['systemLogs', 'agentTraces'].includes(activeSection) }"
>
<template v-if="activeSection === 'profile'">
<section class="settings-card">
<div class="card-head">
@@ -439,9 +442,13 @@
<LogsView v-else class="settings-logs-view" />
</template>
<template v-else-if="activeSection === 'agentTraces'">
<AgentTraceCenterView class="settings-trace-center-view" />
</template>
<template v-else-if="activeSection === 'mail'">
<MailSettingsPanel :mail-form="pageState.mailForm" />
</template>
</template>
</div>
</div>
</div>

View File

@@ -53,7 +53,7 @@
<article class="progress-card panel">
<div class="progress-block">
<div class="progress-head">
<h3>{{ isApplicationDocument ? '申请进度' : isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
<h3>{{ isApplicationDocument ? '申请进度' : '报销进度' }}</h3>
</div>
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
<div
@@ -435,22 +435,16 @@
:key="card.id"
:class="['risk-advice-card', card.tone]"
>
<div class="risk-advice-card-head">
<span>{{ card.label }}</span>
<strong>{{ card.title }}</strong>
<div class="risk-advice-card-main">
<div class="risk-advice-card-head">
<span>{{ card.label }}</span>
<strong>{{ card.title }}</strong>
</div>
<p class="risk-advice-point">{{ card.risk }}</p>
</div>
<p class="risk-advice-point">{{ card.risk }}</p>
<div class="risk-advice-meta">
<div>
<span>规则依据</span>
<ul>
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
</ul>
</div>
<div>
<span>修改建议</span>
<p>{{ card.suggestion }}</p>
</div>
<div class="risk-advice-compact-meta">
<span v-if="card.ruleBasis?.length">{{ card.ruleBasis[0] }}</span>
<em>{{ card.suggestion }}</em>
</div>
</article>
</div>
@@ -461,11 +455,12 @@
v-if="request.claimId"
:claim-id="request.claimId"
/>
<EmployeeProfileRiskCard
v-if="showEmployeeRiskProfile"
:profile="employeeRiskProfile"
:loading="employeeRiskProfileLoading"
:error="employeeRiskProfileError"
<StageRiskAdviceCard
v-if="showStageRiskAdvice"
:request="request"
:expense-items="expenseItems"
:ai-advice="aiAdvice"
:is-application-document="isApplicationDocument"
/>
</section>
</div>
@@ -481,8 +476,8 @@
{{ deleteBusy ? '删除中' : deleteActionLabel }}
</button>
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
<i class="mdi mdi-send-circle-outline"></i>
{{ submitBusy ? '提交中' : '提交审批' }}
<i :class="submitActionIcon"></i>
{{ submitActionLabel }}
</button>
</div>
<div v-else-if="canReturnRequest || canApproveRequest || canPayRequest || canDeleteRequest" class="approval-action-group" aria-label="单据管理操作">
@@ -658,9 +653,9 @@
badge="提交确认"
badge-tone="warning"
:title="`确认提交 ${request.id} 吗?`"
:description="isApplicationDocument ? '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' : '请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。'"
:description="submitConfirmDescription"
cancel-text="返回核对"
confirm-text="确认提交"
:confirm-text="submitConfirmText"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-circle-outline"
@@ -692,7 +687,7 @@
badge="重大风险"
badge-tone="danger"
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
description="如仍需提交审批,请逐条填写违规或超标原因,系统会写入附加说明并用于后续风险统计。"
description="如仍需进入下一步,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
cancel-text="返回整改"
confirm-text="保存原因并继续"
busy-text="保存中..."

View File

@@ -134,20 +134,26 @@ export default {
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canOpenRiskRuleReviewSubmit = computed(
() => false
)
const canOpenRiskRuleReviewSubmit = computed(() => false)
const canSubmitRiskRuleReview = computed(
() =>
canOpenRiskRuleReviewSubmit.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
() => false
)
const canReturnRiskRule = computed(() => false)
const riskRuleHasPublishableRevision = computed(() => {
const revision = selectedSkill.value?.configJson?.revision_draft
return selectedSkillUsesJsonRisk.value && revision &&
revision.generation_status === 'completed' &&
normalizeText(selectedSkill.value?.workingVersion).replace('-', '') &&
selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
})
const canPublishRiskRule = computed(
() =>
false
Boolean(riskRuleHasPublishableRevision.value) &&
canManageSelected.value &&
riskRuleTestPassed.value &&
!detailBusy.value
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
@@ -375,6 +381,7 @@ export default {
canDeleteRiskRule,
canReturnRiskRule,
canPublishRiskRule,
riskRuleHasPublishableRevision,
canToggleRiskRuleEnabled,
canEditRiskRuleDraft,
canCreateRiskRuleRevision,

View File

@@ -1,13 +1,12 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElInput } from 'element-plus/es/components/input/index.mjs'
import { ElPagination } from 'element-plus/es/components/pagination/index.mjs'
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchBudgetSummary } from '../../services/budgets.js'
import { fetchEmployeeMeta } from '../../services/employees.js'
import {
canEditBudgetCenter,
@@ -17,10 +16,19 @@ import {
} from '../../utils/accessControl.js'
import {
BUDGET_QUARTER_OPTIONS,
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
BUDGET_YEAR_OPTIONS,
resolveBudgetExpenseTypeLabel
BUDGET_YEAR_OPTIONS
} from '../../utils/budgetOntology.js'
import {
BUDGET_PAGE_SIZE_OPTIONS,
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
buildBudgetRows,
buildBudgetScopeTabs,
buildBudgetUsageData,
getBudgetStatusOptions,
matchesBudgetKeyword
} from './budgetCenterListModel.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
@@ -31,182 +39,52 @@ const FALLBACK_DEPARTMENTS = [
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
const EXPENSE_BUDGET_SEED = {
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
office: { total: 180000, used: 68500, occupied: 32000, warning: 70, action: '正常' }
function mapOptions(values, suffix = '') {
return values.map((value) => ({
label: suffix ? `${value}${suffix}` : value,
value
}))
}
const DEFAULT_EXPENSE_BUDGET = {
total: 100000,
used: 0,
occupied: 0,
warning: 70,
action: '正常'
function resolveBudgetUpdatedAt(row) {
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
}
const EXPENSE_BLUEPRINTS = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option) => ({
...DEFAULT_EXPENSE_BUDGET,
...EXPENSE_BUDGET_SEED[option.value],
budgetSubjectCode: option.value,
expenseType: option.label
}))
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
const comparison = (value, direction) => ({
value,
tone: direction === 'down' ? 'down' : 'up',
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
})
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit'
})
const BUDGET_COMPILED_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
const normalizePeriodKey = (year, quarter) => {
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim())
? String(quarter || '').trim()
: BUDGET_QUARTER_OPTIONS[0]
return `${normalizedYear}${normalizedQuarter}`
function resolveBudgetCompiler(row) {
return row?.compiler || row?.owner || '-'
}
const parsePercent = (value, fallback = 80) => {
const parsed = Number(String(value || '').replace(/[^\d.-]/g, ''))
return Number.isFinite(parsed) ? parsed : fallback
}
const clampPercent = (value) => Math.min(100, Math.max(0, Number(value) || 0))
function buildThresholds(warning) {
const alert = clampPercent(warning)
return {
reminder: clampPercent(alert - 10),
alert,
risk: clampPercent(alert + 10)
}
}
function formatBudgetCompiledAt(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return BUDGET_COMPILED_TIME_FORMATTER.format(date).replace(/\//g, '-')
}
function resolveBudgetCompiler(item) {
return String(
item?.compiler
|| item?.compiled_by
|| item?.compiledBy
|| item?.created_by
|| item?.createdBy
|| item?.owner_name
|| item?.ownerName
|| '预算编制助手'
).trim()
}
function resolveBudgetReviewer(item) {
return String(
item?.reviewer
|| item?.reviewed_by
|| item?.reviewedBy
|| item?.approved_by
|| item?.approvedBy
|| item?.auditor
|| item?.updated_by
|| item?.updatedBy
|| '高级财务人员'
).trim()
}
function normalizeBudgetAllocationRow(item) {
const balance = item?.balance || {}
const totalAmount = Number(balance.total_amount ?? item?.original_amount ?? 0)
const usedAmount = Number(balance.consumed_amount ?? 0)
const occupiedAmount = Number(balance.reserved_amount ?? 0)
const leftAmount = Number(balance.available_amount ?? 0)
const rate = Number(balance.usage_rate ?? 0)
const warning = parsePercent(item?.warning_threshold, 80)
const thresholds = buildThresholds(warning)
const budgetSubjectCode = String(item?.subject_code || '').trim()
const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode)
return {
allocationId: item?.id || '',
budgetNo: item?.budget_no || '',
budgetSubjectCode,
compiledAt: formatBudgetCompiledAt(item?.created_at || item?.createdAt || item?.updated_at || item?.updatedAt),
compiler: resolveBudgetCompiler(item),
reviewer: resolveBudgetReviewer(item),
expenseType,
totalAmount,
usedAmount,
occupiedAmount,
leftAmount,
rate,
rateTone: rate >= thresholds.risk ? 'danger' : rate >= thresholds.alert ? 'warn' : 'ok',
reminderThreshold: thresholds.reminder,
alertThreshold: thresholds.alert,
riskThreshold: thresholds.risk,
reminderLine: `${thresholds.reminder}%`,
alertLine: `${thresholds.alert}%`,
riskLine: `${thresholds.risk}%`,
total: currency(totalAmount),
used: currency(usedAmount),
occupied: currency(occupiedAmount),
left: currency(leftAmount)
}
}
function normalizeBudgetUsageData(rows) {
const source = Array.isArray(rows) ? rows : []
return {
labels: source.map((item) => item.expenseType || '未分类'),
budget: source.map((item) => Number(item.totalAmount || 0)),
used: source.map((item) => Number(item.usedAmount || 0)),
occupied: source.map((item) => Number(item.occupiedAmount || 0)),
available: source.map((item) => Math.max(Number(item.leftAmount || 0), 0))
}
}
function formatAlertDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ALERT_DATE_FORMATTER.format(date)
}
function normalizeBudgetWarning(item) {
const subjectName = item?.subject_name || resolveBudgetExpenseTypeLabel(item?.subject_code, item?.subject_code)
const departmentName = item?.department_name || ''
const usageRate = Number(item?.usage_rate || 0)
const warningThreshold = Number(item?.warning_threshold || 0)
const tone = item?.severity === 'danger' ? 'danger' : 'warn'
return {
id: item?.allocation_id || `${departmentName}-${subjectName}-${item?.period_key || ''}`,
title: departmentName ? `${departmentName} · ${subjectName}` : subjectName,
desc: item?.message || `使用率已达 ${usageRate}%,达到预警线 ${warningThreshold}%。`,
date: formatAlertDate(item?.occurred_at),
tone
}
function buildBudgetDetailKpis(row) {
return [
{
label: '编制人',
value: resolveBudgetCompiler(row),
unit: '',
meta: row.scope === BUDGET_SCOPE_REVIEW ? '提交草案' : '预算编制',
color: 'var(--theme-primary)'
},
{
label: '审核人',
value: row.reviewer || '-',
unit: '',
meta: '高级财务审核',
color: '#3b82f6'
},
{
label: '版本',
value: row.version || '-',
unit: '',
meta: row.periodType || '预算版本',
color: '#64748b'
},
{
label: '更新时间',
value: resolveBudgetUpdatedAt(row),
unit: '',
meta: '最近同步',
color: '#f59e0b'
}
]
}
export default {
@@ -217,91 +95,71 @@ export default {
default: () => ({})
}
},
emits: ['openAssistant'],
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
TableEmptyState,
TableLoadingState,
ElButton,
ElInput,
ElPagination,
ElTable,
ElTableColumn
ElButton
},
setup(props, { emit }) {
const departments = ref(FALLBACK_DEPARTMENTS)
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
const activeBudgetScope = ref(BUDGET_SCOPE_ALL)
const budgetKeyword = ref('')
const budgetPage = ref(1)
const budgetPageSize = ref(8)
const budgetLoading = ref(true)
const budgetError = ref('')
const selectedBudgetId = ref('')
const filters = ref({
year: '2026',
quarter: 'Q1',
expenseType: '全部',
status: '全部'
})
const budgetPage = ref(1)
const budgetPageSize = ref(5)
const budgetTableKeyword = ref('')
const budgetRows = ref([])
const budgetSummary = ref(null)
const budgetLoading = ref(true)
const budgetError = ref('')
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser))
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
const isDepartmentBudgetMonitor = computed(
() => isBudgetMonitorUser(props.currentUser) && !canSwitchDepartments.value && !isExecutiveUser(props.currentUser)
)
const yearOptions = BUDGET_YEAR_OPTIONS.map((year) => ({ label: `${year}年度`, value: year }))
const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size }))
const departmentOptions = computed(() =>
departments.value.map((department) => ({
label: department.name,
value: department.code
}))
)
const activeDepartment = computed(() =>
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
)
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
const currentUserDepartmentName = computed(() =>
String(props.currentUser?.departmentName || props.currentUser?.department || '').trim()
)
const currentUserCostCenter = computed(() =>
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
)
const departmentRows = computed(() => budgetRows.value)
const filteredBudgetRows = computed(() => {
const keyword = budgetTableKeyword.value.trim().toLowerCase()
return departmentRows.value
.filter((row) => {
if (!keyword) return true
return [
row.compiledAt,
row.compiler,
row.reviewer,
row.expenseType,
row.total,
row.used,
row.occupied,
row.left,
`${row.rate}%`,
row.reminderLine,
row.alertLine,
row.riskLine
].some((value) => String(value || '').toLowerCase().includes(keyword))
})
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
if (filters.value.status === '全部') return true
if (filters.value.status === '预警') return row.rateTone === 'warn'
if (filters.value.status === '管控') return row.rateTone === 'danger'
return row.rateTone === 'ok'
})
})
const yearOptions = mapOptions(BUDGET_YEAR_OPTIONS, '年度')
const quarterOptions = mapOptions(BUDGET_QUARTER_OPTIONS)
const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size }))
const budgetRowsByScope = computed(() =>
buildBudgetRows({
departments: departments.value,
year: filters.value.year,
quarter: filters.value.quarter
})
)
const budgetScopeTabs = computed(() => buildBudgetScopeTabs(budgetRowsByScope.value))
const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || [])
const activeScopeLabel = computed(
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
)
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
const filteredBudgetRows = computed(() =>
activeScopeRows.value
.filter((row) => filters.value.status === '全部' || row.statusLabel === filters.value.status)
.filter((row) => matchesBudgetKeyword(row, budgetKeyword.value))
)
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
const totalBudgetPages = computed(() =>
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 8)))
)
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
@@ -310,102 +168,112 @@ export default {
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 5)
const pageSize = Number(budgetPageSize.value || 8)
const start = (currentBudgetPage.value - 1) * pageSize
return filteredBudgetRows.value.slice(start, start + pageSize)
})
const totals = computed(() => {
const rows = departmentRows.value
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
const used = rows.reduce((sum, item) => sum + item.usedAmount, 0)
const occupied = rows.reduce((sum, item) => sum + item.occupiedAmount, 0)
const selectedBudget = computed(() =>
activeScopeRows.value.find((row) => row.id === selectedBudgetId.value) || null
)
const detailMode = computed(() => Boolean(selectedBudget.value))
const selectedBudgetUsageData = computed(() => buildBudgetUsageData(selectedBudget.value))
const budgetDetailTopBarPayload = computed(() => {
const row = selectedBudget.value
if (!row) return null
return {
total,
used,
occupied,
left: Math.max(total - used - occupied, 0)
view: {
eyebrow: '预算详情',
title: `${row.departmentName} · ${row.periodLabel}`,
desc: `${row.budgetNo} / ${row.version} · 仅覆盖差旅、通信、招待费、办公用品`
},
alerts: [],
kpis: buildBudgetDetailKpis(row)
}
})
const selectedBudgetStatusNotes = computed(() => {
const row = selectedBudget.value
if (!row) return []
const budgetMetrics = computed(() => [
{
label: '预算总额',
value: `¥${currency(totals.value.total)}`,
yoy: comparison('+8.42%', 'up'),
mom: comparison('+2.16%', 'up'),
tone: 'primary',
icon: 'mdi mdi-wallet-outline'
},
{
label: '已发生',
value: `¥${currency(totals.value.used)}`,
yoy: comparison('+12.68%', 'up'),
mom: comparison('+4.35%', 'up'),
tone: 'info',
icon: 'mdi mdi-chart-line'
},
{
label: '已占用',
value: `¥${currency(totals.value.occupied)}`,
yoy: comparison('+6.37%', 'up'),
mom: comparison('-1.84%', 'down'),
tone: 'warning',
icon: 'mdi mdi-briefcase-check-outline'
},
{
label: '剩余可用',
value: `¥${currency(totals.value.left)}`,
yoy: comparison('-3.26%', 'down'),
mom: comparison('-2.08%', 'down'),
tone: 'primary',
icon: 'mdi mdi-cash'
}
])
const visibleDepartments = computed(() => {
const keyword = departmentKeyword.value.trim()
return departments.value
.filter((item) => !keyword || item.name.includes(keyword) || item.code.includes(keyword))
.map((item) => ({
...item,
icon: item.code === activeDepartmentCode.value ? 'mdi mdi-account-group-outline' : 'mdi mdi-domain'
}))
return [
{
label: '预算状态',
value: row.statusLabel,
tone: row.statusTone || 'ok',
desc: row.auditSummary || '当前预算状态已完成同步,可在预算中心继续追踪。'
},
{
label: '风险状态',
value: row.riskLabel,
tone: row.riskTone || 'ok',
desc: `当前已发生与已占用合计使用率为 ${row.usageRateLabel},系统按四类费用的提醒、告警和风险阈值综合判断。`
}
]
})
const showTable = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length > 0)
const showEmpty = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length === 0)
const emptyState = computed(() => ({
eyebrow: activeScopeLabel.value,
title: `暂无${activeScopeLabel.value}`,
desc: '当前筛选条件下没有匹配的预算记录。',
icon: 'mdi mdi-database-search-outline',
tone: 'blue',
artLabel: '预算列表为空',
tips: ['可以调整年度、季度、状态或关键词后重试。']
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
const warnings = computed(() =>
(Array.isArray(budgetSummary.value?.warnings) ? budgetSummary.value.warnings : [])
.map(normalizeBudgetWarning)
)
const budgetUsageData = computed(() =>
normalizeBudgetUsageData(departmentRows.value)
)
function openBudgetAssistant() {
function openBudgetAssistant(prompt = '') {
if (!canEditBudget.value) return
emit('openAssistant', {
source: 'budget',
sessionType: 'budget',
prompt: '',
prompt,
files: [],
conversation: null
})
}
function openBudgetReviewAssistant(row) {
if (!row || !canAuditBudgetDrafts.value) {
openBudgetDetail(row)
return
}
openBudgetAssistant(
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`
)
}
function openBudgetDetail(row) {
if (!row?.id) return
selectedBudgetId.value = row.id
}
function backToList() {
selectedBudgetId.value = ''
}
function handleRowAction(row) {
if (activeBudgetScope.value === BUDGET_SCOPE_REVIEW && canAuditBudgetDrafts.value) {
openBudgetReviewAssistant(row)
return
}
openBudgetDetail(row)
}
function goToBudgetPage(page) {
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
}
function changeBudgetPage(direction) {
goToBudgetPage(currentBudgetPage.value + direction)
function changeBudgetPageSize(size) {
budgetPageSize.value = Number(size) || 8
budgetPage.value = 1
}
function resolveScopedDepartments(options) {
if (!isDepartmentBudgetMonitor.value) {
return options
}
if (!isDepartmentBudgetMonitor.value) return options
const userDepartment = currentUserDepartmentName.value
const userCostCenter = currentUserCostCenter.value
@@ -414,9 +282,7 @@ export default {
return userDepartment && item.name === userDepartment
})
if (scoped.length) {
return scoped
}
if (scoped.length) return scoped
return [
{
@@ -430,6 +296,7 @@ export default {
async function loadDepartments() {
budgetLoading.value = true
budgetError.value = ''
try {
const payload = await fetchEmployeeMeta()
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
@@ -442,39 +309,11 @@ export default {
costCenter: String(item.costCenter || '')
}))
const scopedDepartments = resolveScopedDepartments(nextDepartments)
if (scopedDepartments.length) {
departments.value = scopedDepartments
if (!scopedDepartments.some((item) => item.code === activeDepartmentCode.value)) {
activeDepartmentCode.value = scopedDepartments[0].code
}
}
await loadBudgetData()
} catch (error) {
console.warn('Failed to load budget departments from employee meta:', error)
await loadBudgetData()
}
}
async function loadBudgetData() {
const department = activeDepartment.value || {}
budgetLoading.value = true
budgetError.value = ''
try {
const payload = await fetchBudgetSummary({
year: filters.value.year,
period: normalizePeriodKey(filters.value.year, filters.value.quarter),
department_id: department.id || '',
cost_center: department.costCenter || ''
})
const allocations = Array.isArray(payload?.allocations) ? payload.allocations : []
budgetSummary.value = payload || null
budgetRows.value = allocations.map(normalizeBudgetAllocationRow)
} catch (error) {
budgetError.value = error?.message || 'Failed to load budget data'
budgetSummary.value = null
budgetRows.value = []
console.warn('Failed to load budget data:', error)
} finally {
budgetLoading.value = false
}
@@ -485,24 +324,24 @@ export default {
})
watch(
[
activeDepartmentCode,
budgetPageSize,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.expenseType,
() => filters.value.status,
budgetTableKeyword
],
() => activeBudgetScope.value,
() => {
filters.value.status = '全部'
budgetPage.value = 1
selectedBudgetId.value = ''
}
)
watch(
[activeDepartmentCode, () => filters.value.year, () => filters.value.quarter],
[
budgetPageSize,
budgetKeyword,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.status
],
() => {
void loadBudgetData()
budgetPage.value = 1
}
)
@@ -512,37 +351,51 @@ export default {
}
})
watch(detailMode, (value) => {
emit('detail-open-change', value)
}, { immediate: true })
watch(budgetDetailTopBarPayload, (payload) => {
emit('detail-topbar-change', payload)
}, { immediate: true, deep: true })
return {
activeDepartmentCode,
activeDepartmentName,
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
activeBudgetScope,
budgetError,
budgetKeyword,
budgetLoading,
budgetMetrics,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions,
budgetTableKeyword,
budgetScopeTabs,
backToList,
canAuditBudgetDrafts,
canEditBudget,
canSwitchDepartments,
changeBudgetPage,
departmentKeyword,
departments,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
changeBudgetPageSize,
detailMode,
emptyState,
filters,
openBudgetAssistant,
quarters: BUDGET_QUARTER_OPTIONS,
departmentOptions,
statuses: ['全部', '正常', '预警', '管控'],
goToBudgetPage,
handleRowAction,
openBudgetAssistant,
openBudgetDetail,
openBudgetReviewAssistant,
pageSummary,
quarterOptions,
selectedBudget,
selectedBudgetStatusNotes,
selectedBudgetUsageData,
showEmpty,
showTable,
statusOptions,
totalBudgetPages,
totalBudgetRows,
budgetUsageData,
visibleBudgetRows,
visibleDepartments,
warnings,
yearOptions,
years: BUDGET_YEAR_OPTIONS
yearOptions
}
}
}

View File

@@ -169,13 +169,17 @@ export default {
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
context_json: {
role_codes: currentUser.value?.roleCodes || [],
is_admin: Boolean(currentUser.value?.isAdmin),
name: currentUser.value?.name || '',
role: currentUser.value?.role || '',
position: currentUser.value?.position || '',
grade: currentUser.value?.grade || ''
}
})
is_admin: Boolean(currentUser.value?.isAdmin),
name: currentUser.value?.name || '',
role: currentUser.value?.role || '',
department: currentUser.value?.department || currentUser.value?.departmentName || '',
department_name: currentUser.value?.departmentName || currentUser.value?.department || '',
position: currentUser.value?.position || '',
grade: currentUser.value?.grade || '',
employee_no: currentUser.value?.employeeNo || '',
manager_name: currentUser.value?.managerName || currentUser.value?.manager_name || ''
}
})
} catch (error) {
semanticResult.value = null
semanticError.value = error.message || '语义解析失败,请稍后重试。'

View File

@@ -1,4 +1,5 @@
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
import AgentTraceCenterView from '../AgentTraceCenterView.vue'
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
import LogDetailView from '../LogDetailView.vue'
import LogsView from '../LogsView.vue'
@@ -9,6 +10,7 @@ import { useSettings } from '../../composables/useSettings.js'
export default {
name: 'SettingsView',
components: {
AgentTraceCenterView,
HermesEmployeeSettingsPanel,
EnterpriseSelect,
LlmSettingsPanel,

View File

@@ -556,7 +556,7 @@ export default {
emits: ['close', 'draft-saved', 'request-updated'],
setup(props, { emit }) {
const router = useRouter()
const { currentUser } = useSystemState()
const { currentUser, refreshCurrentUserFromBackend } = useSystemState()
const { toast } = useToast()
const fileInputRef = ref(null)
@@ -1067,6 +1067,7 @@ export default {
createMessage,
currentInsight,
currentUser,
refreshCurrentUserFromBackend,
draftClaimId,
extractReviewAttachmentNames,
failCurrentFlowStep,
@@ -1149,6 +1150,7 @@ export default {
lockSuggestedActionMessage,
submitExistingComposer: submitComposerInternal,
currentUser,
refreshCurrentUserFromBackend,
toast
})
function openTravelCalculator() {
@@ -1493,7 +1495,7 @@ export default {
await switchSessionType(shortcut.targetSessionType)
return
}
if (handleGuidedShortcut(shortcut)) {
if (await handleGuidedShortcut(shortcut)) {
return
}

View File

@@ -7,7 +7,7 @@ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import EmployeeProfileRiskCard from '../../components/travel/EmployeeProfileRiskCard.vue'
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
@@ -16,9 +16,9 @@ import {
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchEmployeeLatestProfile,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
preReviewExpenseClaim,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
@@ -35,6 +35,10 @@ import {
isCurrentRequestApplicant,
isFinanceUser
} from '../../utils/accessControl.js'
import {
buildRiskViewerContext,
filterRiskCardsForVisibility
} from '../../utils/riskVisibility.js'
import {
buildLeaderApprovalEvents,
buildLeaderApprovalInfo,
@@ -52,6 +56,7 @@ import {
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
filterRiskCardsByBusinessStage,
normalizeRiskTone,
resolveRiskTags
} from './travelRequestDetailInsights.js'
@@ -78,6 +83,17 @@ import {
resolveExpenseReasonPlaceholder,
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
import {
buildAiPreReviewSnapshot,
findLatestAiPreReviewEvent,
isAiPreReviewFlag,
isAiPreReviewPassed,
resolveAiPreReviewToast,
resolveSubmitActionIcon,
resolveSubmitActionLabel,
resolveSubmitConfirmDescription,
resolveSubmitConfirmText
} from './travelRequestDetailPreReviewModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
/*
@@ -377,7 +393,7 @@ export default {
components: {
ConfirmDialog,
EnterpriseSelect,
EmployeeProfileRiskCard,
StageRiskAdviceCard,
RiskObservationEvidenceCard,
TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis,
@@ -410,6 +426,8 @@ export default {
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const aiPreReviewSnapshot = ref(null)
const riskFlagPreviewSnapshot = ref(null)
const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
@@ -441,10 +459,6 @@ export default {
})
const detailNoteEditor = ref('')
const savingDetailNote = ref(false)
const employeeRiskProfile = ref(null)
const employeeRiskProfileLoading = ref(false)
const employeeRiskProfileError = ref('')
let employeeRiskProfileLoadSeq = 0
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
@@ -496,7 +510,10 @@ export default {
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
return isEditableRequest.value || canManageCurrentClaim.value
if (canManageCurrentClaim.value) {
return true
}
return isEditableRequest.value && isCurrentApplicant.value
})
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
@@ -533,29 +550,6 @@ export default {
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const employeeProfileId = computed(() =>
String(
request.value.employeeId
|| request.value.employee_id
|| request.value.profileEmployeeId
|| ''
).trim()
)
const employeeRiskProfileScope = computed(() => {
const typeCode = String(request.value.typeCode || request.value.expense_type || '').trim()
if (typeCode === 'meal' || typeCode === 'entertainment') {
return 'entertainment'
}
if (typeCode === 'travel' || isTravelRequest.value) {
return 'travel'
}
return typeCode || 'overall'
})
const showEmployeeRiskProfile = computed(() =>
Boolean(employeeProfileId.value)
&& Boolean(request.value.claimId)
&& !isDraftRequest.value
)
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
@@ -581,6 +575,25 @@ export default {
|| canProcessBudgetApprovalStage.value
)
)
const canViewApprovalRiskAdvice = computed(() => (
Boolean(request.value.claimId)
&& !isDraftRequest.value
&& !isCurrentApplicant.value
&& (canReturnRequest.value || canApproveRequest.value)
))
const showStageRiskAdvice = computed(() => canViewApprovalRiskAdvice.value)
const riskViewerContext = computed(() => buildRiskViewerContext({
request: request.value,
currentUser: currentUser.value,
businessStage: isApplicationDocument.value ? 'expense_application' : 'reimbursement',
isApplicationDocument: isApplicationDocument.value,
isCurrentApplicant: isCurrentApplicant.value,
isBudgetReviewer: canProcessBudgetApprovalStage.value,
isDirectManagerReviewer: isCurrentDirectManagerApprover.value,
isFinanceReviewer: canProcessFinanceApprovalStage.value,
isAdminViewer: canManageCurrentClaim.value,
canViewApprovalRiskAdvice: canViewApprovalRiskAdvice.value
}))
const {
canPayRequest,
closePayConfirmDialog,
@@ -628,7 +641,7 @@ export default {
if (isBudgetApprovalStage.value) {
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
}
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后会流转至预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => {
if (isFinanceApprovalStage.value) {
@@ -643,7 +656,7 @@ export default {
if (isApplicationDocument.value) {
return isBudgetApprovalStage.value
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
: '确认后该申请单会完成直属领导审批,并流转给预算管理者进一步审核。'
: '确认后该申请单会完成直属领导审批,系统将按预算余额、当前风险和历史风险判断是否需要预算管理者复核;无风险且预算充足会直接完成申请并生成报销草稿。'
}
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
})
@@ -666,7 +679,7 @@ export default {
return isApplicationDocument.value
? isBudgetApprovalStage.value
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
: `${request.value.id} 已确认审核,已流转至预算管理者审批`
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程`
: `${request.value.id} 已审批通过,流转至财务审批。`
})
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
@@ -713,6 +726,7 @@ export default {
Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key]
})
aiPreReviewSnapshot.value = null
closeAttachmentPreview()
}
pendingUploadExpenseId.value = ''
@@ -724,19 +738,6 @@ export default {
{ immediate: true }
)
watch(
() => [
employeeProfileId.value,
request.value.claimId,
employeeRiskProfileScope.value,
showEmployeeRiskProfile.value
],
() => {
void loadEmployeeRiskProfile()
},
{ immediate: true }
)
const heroFactItems = computed(() => [
{
key: 'document',
@@ -846,6 +847,12 @@ export default {
},
{ immediate: true }
)
watch(
() => request.value.claimId,
() => {
riskFlagPreviewSnapshot.value = null
}
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
@@ -907,7 +914,25 @@ export default {
function resolveClaimRiskFlags() {
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
let requestFlags = Array.isArray(flags) ? flags : []
const previewSnapshot = riskFlagPreviewSnapshot.value
if (
previewSnapshot
&& previewSnapshot.claimId === request.value?.claimId
&& Array.isArray(previewSnapshot.riskFlags)
) {
requestFlags = previewSnapshot.riskFlags
}
const snapshot = aiPreReviewSnapshot.value
if (
snapshot
&& snapshot.claimId === request.value?.claimId
&& Array.isArray(snapshot.riskFlags)
&& !requestFlags.some(isAiPreReviewFlag)
) {
return snapshot.riskFlags
}
return requestFlags
}
function resolveAttachmentDisplayName(item) {
@@ -953,38 +978,6 @@ export default {
return payload
}
async function loadEmployeeRiskProfile() {
const sequence = ++employeeRiskProfileLoadSeq
employeeRiskProfileError.value = ''
if (!showEmployeeRiskProfile.value) {
employeeRiskProfile.value = null
employeeRiskProfileLoading.value = false
return
}
employeeRiskProfileLoading.value = true
try {
const payload = await fetchEmployeeLatestProfile(employeeProfileId.value, {
scene: 'approval',
claim_id: request.value.claimId,
window_days: 90,
expense_type_scope: employeeRiskProfileScope.value
})
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = payload
}
} catch (error) {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = error?.message || '画像读取失败,请稍后重试。'
}
} finally {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfileLoading.value = false
}
}
}
function canPreviewAttachment(item) {
if (!item?.invoiceId) {
return false
@@ -1100,23 +1093,66 @@ export default {
return summary ? `重大风险警示:${summary}` : '重大风险警示'
}
function applyAiPreReviewPayload(payload) {
aiPreReviewSnapshot.value = buildAiPreReviewSnapshot(payload, request.value.claimId)
}
function applyClaimRiskFlagsPayload(payload) {
const flags = Array.isArray(payload?.claim_risk_flags)
? payload.claim_risk_flags
: Array.isArray(payload?.claimRiskFlags)
? payload.claimRiskFlags
: null
if (!flags) {
return
}
riskFlagPreviewSnapshot.value = {
claimId: request.value.claimId,
riskFlags: flags
}
}
const requiresAiPreReview = computed(() => isEditableRequest.value && !isApplicationDocument.value)
const aiPreReviewEvent = computed(() => findLatestAiPreReviewEvent(resolveClaimRiskFlags()))
const hasAiPreReviewResult = computed(() => !requiresAiPreReview.value || Boolean(aiPreReviewEvent.value))
const aiPreReviewPassed = computed(() =>
isAiPreReviewPassed(aiPreReviewEvent.value, requiresAiPreReview.value)
)
const aiAdvice = computed(() => {
const completionItems = isEditableRequest.value
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
: []
const directRiskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags()
})
const currentBusinessStage = isApplicationDocument.value ? 'expense_application' : 'reimbursement'
const directRiskCards = filterRiskCardsByBusinessStage(
buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags(),
businessStage: currentBusinessStage
}),
currentBusinessStage
)
const hasActionableRiskCards = directRiskCards.some(
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
)
const riskCards = [
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
const summaryRiskCards = filterRiskCardsByBusinessStage(
buildClaimSummaryRiskCards({
...(request.value || {}),
businessStage: currentBusinessStage
}),
currentBusinessStage
)
const optionalRiskCards = filterRiskCardsByBusinessStage(
buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value),
currentBusinessStage
)
const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards,
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
...optionalRiskCards
]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
return buildAiAdviceViewModel({
completionItems,
@@ -1124,13 +1160,54 @@ export default {
})
})
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
const aiAdviceHint = computed(() => (
isEditableRequest.value
? '按建议顺序补齐信息或处理风险后,再发起审批。'
: '展示系统已识别的风险点,便于审批和后续整改。'
const hasVisibleRiskCards = computed(() =>
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
)
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
const showAiAdvicePanel = computed(() => (
(
isEditableRequest.value
&& (
(requiresAiPreReview.value && hasAiPreReviewResult.value)
|| hasAdviceSections.value
)
)
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
))
const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
return '报销风险提示'
}
if (isEditableRequest.value && isApplicationDocument.value) {
return '表单自查提示'
}
return isEditableRequest.value ? 'AI建议' : 'AI提示'
})
const aiAdviceHint = computed(() => (
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
: isEditableRequest.value
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : 'AI预审已完成请按风险提示补充原因或进入下一步。')
: '展示系统已识别的风险点,便于审批和后续整改。'
))
const submitActionLabel = computed(() => {
return resolveSubmitActionLabel({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value,
submitBusy: submitBusy.value
})
})
const submitActionIcon = computed(() => resolveSubmitActionIcon({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value
}))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value,
aiPreReviewPassed: aiPreReviewPassed.value
}))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards
@@ -1470,6 +1547,7 @@ export default {
try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
applyClaimRiskFlagsPayload(payload)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
@@ -1519,6 +1597,7 @@ export default {
deletingAttachmentId.value = item.id
try {
const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id)
applyClaimRiskFlagsPayload(payload)
delete expenseAttachmentMeta[item.id]
applyLocalExpenseItemPatch(item.id, {
invoiceId: '',
@@ -1672,7 +1751,22 @@ export default {
}
}
function handleSubmit() {
async function runAiPreReview() {
submitBusy.value = true
try {
const payload = await preReviewExpenseClaim(request.value.claimId)
applyAiPreReviewPayload(payload)
const event = findLatestAiPreReviewEvent(payload?.risk_flags_json || [])
toast(resolveAiPreReviewToast(event))
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || 'AI预审失败请稍后重试。')
} finally {
submitBusy.value = false
}
}
async function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
@@ -1688,6 +1782,11 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog()
return
@@ -1723,6 +1822,12 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
submitConfirmDialogOpen.value = false
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false
openRiskOverrideDialog()
@@ -1862,6 +1967,14 @@ export default {
approveConfirmDialogOpen.value = false
}
function resolveApproveErrorMessage(error) {
const message = String(error?.message || '').trim()
if (message.includes('未找到同部门 P8 预算审批人')) {
return '当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。'
}
return message || '审批通过失败,请稍后重试。'
}
async function confirmApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
@@ -1889,8 +2002,9 @@ export default {
: approvalSuccessToast.value
)
emit('request-updated', { claimId: request.value.claimId })
emit('backToRequests')
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
toast(resolveApproveErrorMessage(error))
} finally {
approveBusy.value = false
}
@@ -1939,7 +2053,6 @@ export default {
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
employeeRiskProfile, employeeRiskProfileError, employeeRiskProfileLoading,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
@@ -1957,9 +2070,9 @@ export default {
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showEmployeeRiskProfile,
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}

View File

@@ -8,6 +8,16 @@ function resolveRiskScoreCardColor(level) {
return 'var(--theme-primary)'
}
function resolveStateColor(tone, fallback = 'var(--theme-primary)') {
const normalized = normalizeText(tone).toLowerCase()
if (['active', 'success', 'online'].includes(normalized)) return 'var(--success)'
if (['disabled', 'offline', 'draft'].includes(normalized)) return '#64748b'
if (['failed', 'danger', 'critical', 'high'].includes(normalized)) return '#ef4444'
if (['review', 'warning', 'medium'].includes(normalized)) return '#f59e0b'
if (['generating', 'info'].includes(normalized)) return 'var(--theme-primary)'
return fallback
}
export function buildAuditDetailTopBar({
skill,
usesJsonRiskRule = false
@@ -35,6 +45,38 @@ export function buildAuditDetailTopBar({
: 'up',
color: resolveRiskScoreCardColor(scoreLevel)
})
kpis.push({
label: '风险等级',
value: normalizeText(skill.riskRuleSeverityLabel) || '待评估',
unit: '',
meta: normalizeText(skill.riskRuleScoreLabel) || '评分模型',
trend: ['critical', 'high', 'medium'].includes(normalizeText(scoreLevel).toLowerCase()) ? 'down' : 'up',
color: resolveRiskScoreCardColor(scoreLevel)
})
kpis.push({
label: '规则状态',
value: normalizeText(skill.status) || '待上线',
unit: '',
meta: normalizeText(skill.displayVersion) || '工作版本',
trend: '',
color: resolveStateColor(skill.statusTone)
})
kpis.push({
label: '上线状态',
value: normalizeText(skill.isOnlineLabel) || '待上线',
unit: '',
meta: normalizeText(skill.publishedAt) && skill.publishedAt !== '-' ? skill.publishedAt : '未发布',
trend: skill.isOnlineValue ? 'up' : '',
color: resolveStateColor(skill.isOnlineTone)
})
kpis.push({
label: '启用状态',
value: normalizeText(skill.isEnabledLabel) || '否',
unit: '',
meta: skill.isEnabledValue ? '参与扫描' : '不参与扫描',
trend: skill.isEnabledValue ? 'up' : '',
color: resolveStateColor(skill.isEnabledTone)
})
}
return {

View File

@@ -209,6 +209,7 @@ export const RULE_TAB_TAG_ALIASES = {
export const RISK_SCENARIO_OPTIONS = [
{ value: '', label: '全部场景' },
{ value: '全部', label: '全部' },
{ value: '差旅费', label: '差旅费' },
{ value: '住宿费', label: '住宿费' },
{ value: '交通费', label: '交通费' },

View File

@@ -5,15 +5,11 @@ export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
]
export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
{ value: 'all', label: '全部' },
{ value: 'travel', label: '差旅费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'meeting', label: '会务费' },
{ value: 'office', label: '办公用品费' },
{ value: 'training', label: '培训费' },
{ value: 'communication', label: '通讯费' },
{ value: 'welfare', label: '福利费' }
{ value: 'communication', label: '通信费' }
]
export const RISK_RULE_BUSINESS_STAGE_OPTIONS = [
@@ -55,7 +51,7 @@ export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
business_stage: 'reimbursement',
expense_category: 'travel',
expense_category: 'all',
rule_title: '',
requires_attachment: false,
natural_language: ''

View File

@@ -42,6 +42,7 @@ const LAST_OPERATION_LABELS = {
test: '测试',
online: '上线',
offline: '下线',
generation_failed: '生成失败',
delete: '删除',
update: '更新'
}

View File

@@ -14,6 +14,7 @@ import {
} from './auditViewDataUtils.js'
import { formatScenarioList } from './auditViewFormatters.js'
const EXPENSE_TYPE_SCENARIO_LABELS = {
all: '全部',
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
@@ -142,6 +143,10 @@ export function normalizeRiskScenarioCategory(value) {
export function normalizeExpenseTypeScenarioLabels(value) {
const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : []
if (values.some((item) => ['all', '*', 'overall', 'general', '全部', '通用'].includes(normalizeText(item).toLowerCase()))) {
return ['全部']
}
const labels = []
const seen = new Set()

View File

@@ -105,6 +105,10 @@ function parseYear(rawText) {
return match ? Number(match[1]) : 2026
}
function hasExplicitYear(rawText) {
return /(20\d{2})/.test(String(rawText || ''))
}
function resolvePreviousPeriod(year, quarter) {
if (quarter > 1) {
return { year, quarter: quarter - 1 }
@@ -117,35 +121,52 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) {
return false
}
const text = normalizeBudgetText(rawText)
const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText)
return Boolean(
text &&
/(预算|budget)/.test(text) &&
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
parseQuarter(rawText)
hasTargetPeriod
)
}
export function buildBudgetCompileReport(rawText, user = {}) {
const targetYear = parseYear(rawText)
const targetQuarter = parseQuarter(rawText) || 3
const previous = resolvePreviousPeriod(targetYear, targetQuarter)
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value, 0)
const totalBudget = 1320000
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget, 0)
const parsedQuarter = parseQuarter(rawText)
const isAnnualBudget = !parsedQuarter
const targetQuarter = parsedQuarter || 1
const previous = isAnnualBudget
? { year: targetYear - 1, quarter: 0 }
: resolvePreviousPeriod(targetYear, targetQuarter)
const periodMultiplier = isAnnualBudget ? 4 : 1
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0)
const totalBudget = 1320000 * periodMultiplier
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0)
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
const value = item.value * periodMultiplier
const previousValue = item.previousValue * periodMultiplier
const recommendedBudget = item.recommendedBudget * periodMultiplier
const trendValue = item.previousValue
? ((item.value - item.previousValue) / item.previousValue) * 100
? ((value - previousValue) / previousValue) * 100
: 0
return {
...item,
amountDisplay: compactCurrency(item.value),
display: percent(item.value, totalSpend),
share: percent(item.value, totalSpend),
value,
previousValue,
recommendedBudget,
amountDisplay: compactCurrency(value),
display: percent(value, totalSpend),
share: percent(value, totalSpend),
trend: `${trendValue >= 0 ? '+' : ''}${trendValue.toFixed(1)}%`,
trendTone: trendValue >= 10 ? 'risk' : trendValue >= 0 ? 'warn' : 'stable',
recommendedDisplay: compactCurrency(item.recommendedBudget)
recommendedDisplay: compactCurrency(recommendedBudget),
editableBudget: recommendedBudget,
reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70,
alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80,
riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90,
editNote: item.suggestion
}
})
@@ -158,13 +179,18 @@ export function buildBudgetCompileReport(rawText, user = {}) {
return {
type: 'budget_compile_analysis',
title: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: `基于${previous.year}${previous.quarter}度预算执行模拟数据`,
title: isAnnualBudget
? `${targetYear}度预算编制前置分析报告`
: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: isAnnualBudget
? `基于${previous.year}年度预算执行模拟数据`
: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`,
departmentName,
targetPeriod: `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`,
basePeriod: `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
targetPeriod: isAnnualBudget ? `${targetYear}年度` : `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`,
basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
periodType: isAnnualBudget ? '年度预算' : '季度预算',
centerValue: compactCurrency(totalSpend),
centerLabel: '上季度开销',
centerLabel: isAnnualBudget ? '去年开销' : '上季度开销',
summary: {
totalBudget: compactCurrency(totalBudget),
totalSpend: compactCurrency(totalSpend),
@@ -172,13 +198,25 @@ export function buildBudgetCompileReport(rawText, user = {}) {
recommendedTotal: compactCurrency(recommendedTotal)
},
macroInsights: [
`${previous.year}${previous.quarter}季度实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${targetYear}${targetQuarter}季度预算编制的第一优先级。`,
`${isAnnualBudget ? `${previous.year}年度` : `${previous.year}${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}预算编制的第一优先级。`,
`${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
],
items,
editableDraft: {
status: 'editing',
rows: items.map((item) => ({
key: item.key,
name: item.name,
budgetAmount: item.editableBudget,
reminderThreshold: item.reminderThreshold,
alertThreshold: item.alertThreshold,
riskThreshold: item.riskThreshold,
note: item.editNote
}))
},
recommendations: [
`建议${targetYear}${targetQuarter}季度总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
],

View File

@@ -0,0 +1,365 @@
import {
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
formatBudgetPeriod
} from '../../utils/budgetOntology.js'
export const BUDGET_SCOPE_ALL = 'all'
export const BUDGET_SCOPE_REVIEW = 'review'
export const BUDGET_SCOPE_ARCHIVE = 'archive'
export const BUDGET_SCOPE_TABS = Object.freeze([
{ value: BUDGET_SCOPE_ALL, label: '全部预算' },
{ value: BUDGET_SCOPE_REVIEW, label: '预算审核' },
{ value: BUDGET_SCOPE_ARCHIVE, label: '归档预算' }
])
export const BUDGET_PAGE_SIZE_OPTIONS = Object.freeze([8, 12, 20])
const STATUS_OPTIONS_BY_SCOPE = Object.freeze({
[BUDGET_SCOPE_ALL]: ['全部', '正常', '预警', '管控'],
[BUDGET_SCOPE_REVIEW]: ['全部', '待审核', '复核中', '待补充', '已驳回'],
[BUDGET_SCOPE_ARCHIVE]: ['全部', '已归档', '已替换', '已驳回']
})
const DEPARTMENT_PROFILE = Object.freeze({
'MARKET-DEPT': { factor: 1.22, owner: '周明悦', reviewer: '陈思远', riskShift: 12 },
'FINANCE-DEPT': { factor: 0.74, owner: '韩清', reviewer: '沈知行', riskShift: -6 },
'TECH-DEPT': { factor: 1.08, owner: '林子昂', reviewer: '陈思远', riskShift: 4 },
'HR-DEPT': { factor: 0.68, owner: '许婉', reviewer: '沈知行', riskShift: 2 },
'PRODUCTION-DEPT': { factor: 1.36, owner: '赵屿', reviewer: '陈思远', riskShift: 8 },
'PRESIDENT-OFFICE': { factor: 0.92, owner: '孟澜', reviewer: '沈知行', riskShift: -2 }
})
const DEFAULT_PROFILE = Object.freeze({
factor: 1,
owner: '预算编制助手',
reviewer: '高级财务人员',
riskShift: 0
})
const CATEGORY_SEED = Object.freeze({
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80 },
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70 },
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80 },
office: { total: 180000, used: 68500, occupied: 32000, warning: 70 }
})
const QUARTER_FACTOR = Object.freeze({
Q1: 0.92,
Q2: 1,
Q3: 1.12,
Q4: 1.18
})
const REVIEW_STATUS_SEQUENCE = ['待审核', '待审核', '复核中', '待补充', '待审核', '已驳回']
const ARCHIVE_STATUS_SEQUENCE = ['已归档', '已替换', '已驳回', '已归档', '已替换', '已归档']
export function currency(value) {
return Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
export function money(value) {
return `¥${currency(value)}`
}
export function getBudgetStatusOptions(scope) {
return STATUS_OPTIONS_BY_SCOPE[scope] || STATUS_OPTIONS_BY_SCOPE[BUDGET_SCOPE_ALL]
}
export function buildBudgetScopeTabs(rowsByScope) {
return BUDGET_SCOPE_TABS.map((tab) => ({
...tab,
count: Array.isArray(rowsByScope?.[tab.value]) ? rowsByScope[tab.value].length : 0
}))
}
export function buildBudgetRows({ departments = [], year = '2026', quarter = 'Q1' } = {}) {
const scopedDepartments = Array.isArray(departments) ? departments : []
return {
[BUDGET_SCOPE_ALL]: scopedDepartments.map((department, index) =>
buildActiveBudgetRow(department, index, { year, quarter })
),
[BUDGET_SCOPE_REVIEW]: scopedDepartments.map((department, index) =>
buildReviewBudgetRow(department, index, { year, quarter })
),
[BUDGET_SCOPE_ARCHIVE]: scopedDepartments.map((department, index) =>
buildArchiveBudgetRow(department, index, { year, quarter })
)
}
}
export function buildBudgetUsageData(row) {
const categories = Array.isArray(row?.categoryRows) ? row.categoryRows : []
return {
labels: categories.map((item) => item.name),
budget: categories.map((item) => item.amount),
used: categories.map((item) => item.used),
occupied: categories.map((item) => item.occupied),
available: categories.map((item) => item.available)
}
}
export function matchesBudgetKeyword(row, keyword) {
const normalized = String(keyword || '').trim().toLowerCase()
if (!normalized) return true
return String(row?.searchText || '').includes(normalized)
}
function buildActiveBudgetRow(department, index, context) {
const profile = resolveProfile(department)
const categoryRows = buildCategoryRows(department, index, context)
const totals = summarizeCategories(categoryRows)
const risk = resolveRisk(totals.usageRate)
const periodLabel = formatBudgetPeriod(context.year, context.quarter)
const budgetNo = `BUD-${context.year}-${department.code || index + 1}`
return enrichSearchText({
id: `${budgetNo}-ACTIVE`,
scope: BUDGET_SCOPE_ALL,
budgetNo,
departmentCode: department.code || '',
departmentName: department.name || '当前部门',
costCenter: department.costCenter || '',
periodLabel,
periodType: '季度预算',
budgetYear: `${context.year}年度`,
budgetQuarter: context.quarter,
version: `V${index + 1}.0`,
owner: profile.owner,
reviewer: profile.reviewer,
annualAmount: totals.annualAmount,
quarterAmount: totals.total,
monthAmount: totals.total / 3,
usedAmount: totals.used,
occupiedAmount: totals.occupied,
availableAmount: totals.available,
annualAmountLabel: money(totals.annualAmount),
quarterAmountLabel: money(totals.total),
monthAmountLabel: money(totals.total / 3),
usedAmountLabel: money(totals.used),
occupiedAmountLabel: money(totals.occupied),
availableAmountLabel: money(totals.available),
usageRate: totals.usageRate,
usageRateLabel: `${totals.usageRate}%`,
riskTone: risk.tone,
riskLabel: risk.label,
statusLabel: risk.tone === 'risk' ? '管控' : risk.tone === 'alert' ? '预警' : '正常',
statusTone: risk.tone,
updatedAt: `2026-05-${String(28 - index).padStart(2, '0')} 16:${String(20 + index).padStart(2, '0')}`,
categoryRows,
periodRows: buildPeriodRows(totals),
auditSummary: '已通过高级财务审核并发布为正式预算。',
actionLabel: '查看详情'
})
}
function buildReviewBudgetRow(department, index, context) {
const activeRow = buildActiveBudgetRow(department, index, context)
const profile = resolveProfile(department)
const statusLabel = REVIEW_STATUS_SEQUENCE[index % REVIEW_STATUS_SEQUENCE.length]
const applyFactor = 1 + (index % 3) * 0.06 + Math.max(profile.riskShift, 0) / 200
const requestedAmount = activeRow.quarterAmount * applyFactor
const changeRate = Number(((requestedAmount / activeRow.quarterAmount - 1) * 100).toFixed(1))
const risk = resolveRisk(activeRow.usageRate + profile.riskShift)
const categoryRows = activeRow.categoryRows.map((item) => ({
...item,
amount: Math.round(item.amount * applyFactor),
amountLabel: money(Math.round(item.amount * applyFactor)),
note: buildReviewNote(item.name, risk.tone)
}))
return enrichSearchText({
...activeRow,
id: `${activeRow.budgetNo}-DRAFT`,
scope: BUDGET_SCOPE_REVIEW,
budgetNo: `DRF-${context.year}-${department.code || index + 1}`,
periodType: '预算草案',
version: `草案 V1.${index}`,
compiler: profile.owner,
submittedAt: `2026-05-${String(26 + (index % 3)).padStart(2, '0')} ${String(10 + index).padStart(2, '0')}:20`,
requestedAmount,
requestedAmountLabel: money(requestedAmount),
previousAmountLabel: activeRow.quarterAmountLabel,
changeRate,
changeRateLabel: `${changeRate >= 0 ? '+' : ''}${changeRate}%`,
aiScore: Math.max(68, Math.min(94, 88 - index * 2 - Math.max(profile.riskShift, 0))),
riskTone: risk.tone,
riskLabel: risk.label,
statusLabel,
statusTone: resolveReviewStatusTone(statusLabel),
categoryRows,
periodRows: buildPeriodRows({
total: requestedAmount,
annualAmount: requestedAmount * 4,
used: activeRow.usedAmount,
occupied: activeRow.occupiedAmount,
available: Math.max(requestedAmount - activeRow.usedAmount - activeRow.occupiedAmount, 0),
usageRate: percent(activeRow.usedAmount + activeRow.occupiedAmount, requestedAmount)
}),
auditSummary: '等待高级财务人员审核,审核通过后才能发布到正式预算中心。',
actionLabel: '进入审核'
})
}
function buildArchiveBudgetRow(department, index, context) {
const activeRow = buildActiveBudgetRow(department, index, context)
const statusLabel = ARCHIVE_STATUS_SEQUENCE[index % ARCHIVE_STATUS_SEQUENCE.length]
const archiveFactor = statusLabel === '已驳回' ? 0.96 : 0.9
return enrichSearchText({
...activeRow,
id: `${activeRow.budgetNo}-ARCHIVE`,
scope: BUDGET_SCOPE_ARCHIVE,
budgetNo: `ARC-${context.year}-${department.code || index + 1}`,
version: `历史 V${Math.max(1, index)}.${index % 3}`,
periodType: '历史预算',
archiveType: statusLabel === '已驳回' ? '审核驳回' : statusLabel === '已替换' ? '版本替换' : '周期归档',
quarterAmount: activeRow.quarterAmount * archiveFactor,
quarterAmountLabel: money(activeRow.quarterAmount * archiveFactor),
reviewer: resolveProfile(department).reviewer,
archivedAt: `2026-05-${String(12 + index).padStart(2, '0')} 18:00`,
statusLabel,
statusTone: statusLabel === '已驳回' ? 'risk' : 'archived',
auditSummary: statusLabel === '已驳回'
? '该预算版本未通过审核,已保留驳回记录。'
: '该预算版本已进入历史归档,可用于审计追溯。',
actionLabel: '查看归档'
})
}
function buildCategoryRows(department, index, context) {
const profile = resolveProfile(department)
const quarterFactor = QUARTER_FACTOR[context.quarter] || 1
const usageShift = 1 + profile.riskShift / 100
return BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option, categoryIndex) => {
const seed = CATEGORY_SEED[option.value] || { total: 100000, used: 0, occupied: 0, warning: 70 }
const amount = Math.round(seed.total * profile.factor * quarterFactor * (1 + categoryIndex * 0.015))
const used = Math.round(seed.used * profile.factor * usageShift)
const occupied = Math.round(seed.occupied * profile.factor * Math.max(0.8, usageShift))
const available = Math.max(amount - used - occupied, 0)
const usageRate = percent(used + occupied, amount)
const thresholds = buildThresholds(seed.warning)
const risk = resolveRisk(usageRate, thresholds)
return {
code: option.value,
name: option.label,
amount,
used,
occupied,
available,
amountLabel: money(amount),
usedLabel: money(used),
occupiedLabel: money(occupied),
availableLabel: money(available),
usageRate,
usageRateLabel: `${usageRate}%`,
reminderLine: `${thresholds.reminder}%`,
alertLine: `${thresholds.alert}%`,
riskLine: `${thresholds.risk}%`,
riskTone: risk.tone,
riskLabel: risk.label,
note: buildCategoryNote(option.label, risk.tone, index)
}
})
}
function buildPeriodRows(totals) {
return [
{ label: '年度预算', value: money(totals.annualAmount), desc: '按四类费用预算汇总' },
{ label: '季度预算', value: money(totals.total), desc: '当前列表筛选周期' },
{ label: '月度预算', value: money(totals.total / 3), desc: '按季度预算月均拆分' }
]
}
function summarizeCategories(rows) {
const total = rows.reduce((sum, item) => sum + item.amount, 0)
const used = rows.reduce((sum, item) => sum + item.used, 0)
const occupied = rows.reduce((sum, item) => sum + item.occupied, 0)
const available = Math.max(total - used - occupied, 0)
return {
total,
annualAmount: total * 4,
used,
occupied,
available,
usageRate: percent(used + occupied, total)
}
}
function resolveProfile(department) {
return {
...DEFAULT_PROFILE,
...(DEPARTMENT_PROFILE[department?.code] || {})
}
}
function percent(value, total) {
if (!Number(total)) return 0
return Number(((Number(value || 0) / Number(total)) * 100).toFixed(1))
}
function buildThresholds(warning) {
const alert = clampPercent(warning)
return {
reminder: clampPercent(alert - 10),
alert,
risk: clampPercent(alert + 10)
}
}
function clampPercent(value) {
return Math.min(100, Math.max(0, Number(value) || 0))
}
function resolveRisk(value, thresholds = { reminder: 70, alert: 80, risk: 90 }) {
const rate = Number(value || 0)
if (rate >= thresholds.risk) return { label: '风险', tone: 'risk' }
if (rate >= thresholds.alert) return { label: '告警', tone: 'alert' }
if (rate >= thresholds.reminder) return { label: '提醒', tone: 'reminder' }
return { label: '正常', tone: 'ok' }
}
function resolveReviewStatusTone(status) {
if (status === '待补充') return 'alert'
if (status === '已驳回') return 'risk'
if (status === '复核中') return 'reminder'
return 'pending'
}
function buildCategoryNote(name, tone, index) {
if (tone === 'risk') return `${name}使用率已接近风险线,需要重点复核。`
if (tone === 'alert') return `${name}超过告警线,建议核对业务计划和已占用金额。`
if (tone === 'reminder') return `${name}接近提醒线,后续应持续观察。`
return index % 2 === 0 ? `${name}预算执行稳定。` : `${name}仍在正常预算区间。`
}
function buildReviewNote(name, tone) {
if (tone === 'risk') return `${name}预算增幅较高,审核时需要补充业务依据。`
if (tone === 'alert') return `${name}建议结合上一季度发生额复核。`
return `${name}建议按部门编制说明核对。`
}
function enrichSearchText(row) {
const values = [
row.budgetNo,
row.departmentName,
row.costCenter,
row.periodLabel,
row.periodType,
row.version,
row.owner,
row.compiler,
row.reviewer,
row.statusLabel,
row.riskLabel,
row.archiveType
]
return {
...row,
searchText: values.filter(Boolean).join(' ').toLowerCase()
}
}

View File

@@ -21,6 +21,7 @@ const KNOWLEDGE_JOB_TYPES = new Set([
const TASK_TYPE_LABELS = {
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
risk_clue_collect: '风险线索归集',
finance_policy_knowledge_organize: '知识制度整理',
knowledge_index_sync: '知识制度整理',
llm_wiki_sync: '知识制度整理',
@@ -30,6 +31,7 @@ const TASK_TYPE_LABELS = {
const TASK_CODE_TO_TYPE = {
'task.hermes.global_risk_scan': 'global_risk_scan',
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
'task.hermes.risk_rule_discovery': 'risk_clue_collect',
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
}
@@ -56,6 +58,9 @@ function resolveTaskTypeFromToolName(value) {
if (name.includes('finance_policy_knowledge')) {
return 'finance_policy_knowledge_organize'
}
if (name.includes('risk_clue')) {
return 'risk_clue_collect'
}
return ''
}
@@ -136,6 +141,9 @@ export function resolveWorkRecordProductKind(run) {
if (taskType === 'employee_behavior_profile_scan') {
return 'employee_profile'
}
if (taskType === 'risk_clue_collect') {
return 'risk_clue'
}
if (KNOWLEDGE_JOB_TYPES.has(taskType)) {
return 'knowledge'
}

View File

@@ -158,6 +158,8 @@ export function filterDigitalEmployees(items = [], filters = {}) {
const searchText = normalizeText(filters.keyword).toLowerCase()
const hasKeyword = Boolean(searchText)
const hasStatus = Boolean(filters.selectedStatus)
const selectedSkillCategory = normalizeText(filters.selectedSkillCategory)
const hasSkillCategory = Boolean(selectedSkillCategory)
const hasEnabled = Boolean(filters.selectedEnabledState)
const hasExecutionMode = Boolean(filters.selectedExecutionMode)
@@ -168,6 +170,9 @@ export function filterDigitalEmployees(items = [], filters = {}) {
if (hasStatus && item.statusValue !== filters.selectedStatus) {
return false
}
if (hasSkillCategory && normalizeText(item.skillCategory) !== selectedSkillCategory) {
return false
}
if (hasEnabled && (filters.selectedEnabledState === 'enabled') !== Boolean(item.isEnabledValue)) {
return false
}

View File

@@ -0,0 +1,131 @@
export const emptyDigitalEmployeeDashboard = {
windowDays: 7,
generatedAt: '',
hasRealData: false,
totals: {
totalRuns: 0,
successRuns: 0,
failedRuns: 0,
runningRuns: 0,
toolCalls: 0,
businessOutputs: 0,
riskObservations: 0,
riskClues: 0,
profileSnapshots: 0,
knowledgeDocuments: 0,
successRate: 0,
failureRate: 0
},
dailyWork: [],
taskDistribution: [],
categoryDistribution: [
{ name: '积累', value: 0, count: 0, color: 'var(--chart-blue)', description: '沉淀画像、基线和反馈样本' },
{ name: '升级', value: 0, count: 0, color: 'var(--chart-amber)', description: '输出待复核线索和优化建议' },
{ name: '整理', value: 0, count: 0, color: 'var(--success)', description: '整理制度、条款、知识和样本' },
{ name: '评估', value: 0, count: 0, color: 'var(--theme-primary)', description: '评估异常、风险和一致性' }
],
recentRuns: []
}
export function buildDigitalEmployeeKpiMetrics(dashboard, formatNumberCompact) {
const data = dashboard || emptyDigitalEmployeeDashboard
const totals = data.totals || emptyDigitalEmployeeDashboard.totals
const rows = [
{
label: '工作总数',
value: formatNumberCompact(totals.totalRuns),
changeText: `${data.windowDays || 7}`,
delta: '后台任务',
trend: 'up',
icon: 'mdi mdi-briefcase-clock-outline',
accent: 'var(--theme-primary)'
},
{
label: '成功数量',
value: formatNumberCompact(totals.successRuns),
changeText: `${Number(totals.successRate || 0).toFixed(1)}%`,
delta: '运行成功率',
trend: 'up',
icon: 'mdi mdi-check-decagram-outline',
accent: 'var(--success)'
},
{
label: '失败数量',
value: formatNumberCompact(totals.failedRuns),
changeText: `${Number(totals.failureRate || 0).toFixed(1)}%`,
delta: '需排查',
trend: Number(totals.failedRuns || 0) > 0 ? 'down' : 'up',
icon: 'mdi mdi-alert-circle-outline',
accent: '#ef4444'
},
{
label: '业务产出',
value: formatNumberCompact(totals.businessOutputs),
changeText: '累计',
delta: '观察/线索/快照/文档',
trend: 'up',
icon: 'mdi mdi-chart-box-outline',
accent: '#0f766e'
},
{
label: '工具调用',
value: formatNumberCompact(totals.toolCalls),
changeText: '执行链',
delta: '工具执行次数',
trend: 'up',
icon: 'mdi mdi-tools',
accent: '#2563eb'
},
{
label: '运行中',
value: formatNumberCompact(totals.runningRuns),
changeText: Number(totals.runningRuns || 0) > 0 ? '进行中' : '空闲',
delta: '当前窗口',
trend: Number(totals.runningRuns || 0) > 0 ? 'down' : 'up',
icon: 'mdi mdi-progress-clock',
accent: '#f59e0b'
}
]
return rows.map((item, index) => ({
...item,
displayValue: item.value,
delay: index * 55
}))
}
export function buildDigitalEmployeeDailyRows(dashboard) {
const rows = Array.isArray(dashboard?.dailyWork) ? dashboard.dailyWork : []
return rows.map((item) => ({
date: String(item.date || '').trim() || '-',
total: Number(item.total || 0),
success: Number(item.success || 0),
failed: Number(item.failed || 0),
running: Number(item.running || 0),
riskObservations: Number(item.riskObservations || 0),
riskClues: Number(item.riskClues || 0),
profileSnapshots: Number(item.profileSnapshots || 0),
knowledgeDocuments: Number(item.knowledgeDocuments || 0),
businessOutputs: Number(item.businessOutputs || 0)
}))
}
export function buildDigitalEmployeeTaskRanking(dashboard) {
return (dashboard?.taskDistribution || [])
.slice(0, 6)
.map((item) => ({
name: item.name,
shortName: item.name,
value: Number(item.value || item.count || 0),
color: item.color || 'var(--theme-primary)'
}))
}
export function buildDigitalEmployeeCategoryRows(dashboard) {
return (dashboard?.categoryDistribution || [])
.map((item) => ({
...item,
value: Number(item.value || item.count || 0),
count: Number(item.count || item.value || 0)
}))
}

View File

@@ -0,0 +1,124 @@
import { computed, ref } from 'vue'
export function createReceiptDetailDashboardModel({
detailForm,
editableOtherFields,
formatDateTime,
formatScore,
selectedReceipt
}) {
const previewZoom = ref(1)
const previewRotation = ref(0)
const previewTransform = computed(() => `scale(${previewZoom.value}) rotate(${previewRotation.value}deg)`)
const previewPageLabel = computed(() => {
const pageCount = Number(selectedReceipt.value?.page_count || 1)
return `1 / ${Number.isFinite(pageCount) && pageCount > 0 ? pageCount : 1}`
})
const ocrPreviewFields = computed(() => (
editableOtherFields.value
.filter((field) => String(field?.label || field?.key || field?.value || '').trim())
.slice(0, 6)
))
const basicInfoItems = computed(() => [
{ label: '票据类型', value: fallback(detailForm.document_type_label) },
{ label: '票据名称', value: fallback(detailForm.file_name) },
{ label: '提交人', value: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '当前用户') },
{ label: '上传时间', value: formatDateTime(selectedReceipt.value?.uploaded_at) },
{ label: '所属单据编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
{ label: 'OCR 置信度', value: formatScore(selectedReceipt.value?.avg_score) }
])
const receiptStatusItems = computed(() => {
const linked = selectedReceipt.value?.status === 'linked'
return [
{ label: '识别状态', value: '识别成功', tone: 'success' },
{ label: '关联状态', value: selectedReceipt.value?.status_label || (linked ? '已关联' : '未关联'), tone: linked ? 'success' : 'warning' },
{ label: '重复报销风险', value: '无风险', tone: 'success' },
{ label: '归档状态', value: linked ? '待归档' : '未归档', tone: 'info' }
]
})
const linkedClaimItems = computed(() => [
{ label: '报销单编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
{ label: '报销单名称', value: linkedClaimName.value },
{ label: '费用类型', value: fallback(detailForm.scene_label) },
{ label: '申请日期', value: dateOnly(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at) },
{ label: '审批状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '待关联' },
{ label: '是否已入账', value: '未入账' }
])
const operationLogs = computed(() => [
{
time: formatDateTime(selectedReceipt.value?.uploaded_at),
operator: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '系统'),
label: '上传票据'
},
{
time: formatDateTime(selectedReceipt.value?.uploaded_at),
operator: '系统',
label: `OCR识别提取 ${editableOtherFields.value.length} 项要素`
},
{
time: formatDateTime(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at),
operator: selectedReceipt.value?.status === 'linked' ? '系统' : '待处理',
label: selectedReceipt.value?.status === 'linked' ? `关联单据 ${selectedReceipt.value?.linked_claim_no || ''}` : '等待关联单据'
}
])
const archiveInfoItems = computed(() => [
{ label: '归档编号', value: archiveNo.value },
{ label: '归档目录', value: `${dateOnly(selectedReceipt.value?.uploaded_at)} / ${fallback(detailForm.scene_label)}` },
{ label: '保管期限', value: '10年' },
{ label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' },
{ label: '文件格式', value: fileFormat.value },
{ label: '文件大小', value: fallback(selectedReceipt.value?.file_size_label || selectedReceipt.value?.size_label, '待统计') }
])
const linkedClaimName = computed(() => (
selectedReceipt.value?.linked_claim_no
? `${fallback(detailForm.scene_label)}票据归集`
: '暂未关联报销单'
))
const archiveNo = computed(() => (
selectedReceipt.value?.id ? `DA-${String(selectedReceipt.value.id).slice(0, 8).toUpperCase()}` : '待生成'
))
const fileFormat = computed(() => {
const fileName = String(detailForm.file_name || selectedReceipt.value?.file_name || '').trim()
const suffix = fileName.includes('.') ? fileName.split('.').pop() : ''
return suffix ? suffix.toUpperCase() : fallback(selectedReceipt.value?.preview_kind, '待识别')
})
function adjustPreviewZoom(delta) {
previewZoom.value = Math.min(1.8, Math.max(0.6, Number((previewZoom.value + delta).toFixed(2))))
}
function resetPreviewView() {
previewZoom.value = 1
previewRotation.value = 0
}
function rotatePreview() {
previewRotation.value = (previewRotation.value + 90) % 360
}
return {
adjustPreviewZoom,
archiveInfoItems,
basicInfoItems,
linkedClaimItems,
ocrPreviewFields,
operationLogs,
previewPageLabel,
previewRotation,
previewTransform,
previewZoom,
receiptStatusItems,
resetPreviewView,
rotatePreview
}
}
function fallback(value, empty = '待补充') {
const text = String(value || '').trim()
return text || empty
}
function dateOnly(value) {
const text = String(value || '').trim()
return text ? text.slice(0, 10) : '待确认'
}

View File

@@ -503,6 +503,7 @@ export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
@@ -515,6 +516,7 @@ export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',

View File

@@ -3,6 +3,11 @@ import {
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import {
resolveRiskActionability,
resolveRiskDomain,
resolveRiskVisibilityScope
} from '../../utils/riskVisibility.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
@@ -28,6 +33,121 @@ function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function normalizeBusinessStage(value) {
const stage = normalizeText(value).toLowerCase()
if ([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
].includes(stage)) {
return 'expense_application'
}
if ([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
].includes(stage)) {
return 'reimbursement'
}
return ''
}
function resolveFlagBusinessStage(flag, fallback = 'reimbursement') {
if (!flag || typeof flag !== 'object') {
return resolveRiskTextBusinessStage(flag, fallback)
}
const explicitStage = normalizeBusinessStage(
flag.businessStage
|| flag.business_stage
|| flag.controlStage
|| flag.control_stage
)
if (explicitStage) {
return explicitStage
}
const source = normalizeText(flag.source).toLowerCase()
const eventType = normalizeText(flag.event_type || flag.eventType).toLowerCase()
if (source === 'attachment_analysis' || /expense_claim|reimbursement|payment/.test(eventType)) {
return 'reimbursement'
}
if (/application/.test(source) || /expense_application/.test(eventType)) {
return 'expense_application'
}
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
}
function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
const text = normalizeText(value)
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
return 'reimbursement'
}
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
return 'expense_application'
}
return fallback
}
function cardLikeText(card = {}) {
return [
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}
function resolveRequestBusinessStage(request = {}) {
const explicitStage = normalizeBusinessStage(
request?.businessStage
|| request?.business_stage
|| request?.controlStage
|| request?.control_stage
)
if (explicitStage) {
return explicitStage
}
const documentType = normalizeText(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
).toLowerCase()
if (['application', 'expense_application'].includes(documentType)) {
return 'expense_application'
}
const claimNo = normalizeText(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.document_no
|| request?.id
).toUpperCase()
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
return 'expense_application'
}
const typeCode = normalizeText(request?.typeCode || request?.expense_type).toLowerCase()
if (typeCode === 'application' || typeCode.endsWith('_application')) {
return 'expense_application'
}
return 'reimbursement'
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
@@ -37,6 +157,14 @@ function normalizeTone(value) {
return 'medium'
}
function resolveRiskLevelLabel(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return '高风险'
if (normalizedTone === 'medium') return '中风险'
if (normalizedTone === 'low') return '低风险'
return '风险提示'
}
export function normalizeRiskTone(value) {
return normalizeTone(value)
}
@@ -143,12 +271,34 @@ export function resolveRiskTags(card = {}) {
}
function withRiskTags(card) {
const businessStage = normalizeBusinessStage(
card.businessStage
|| card.business_stage
|| card.controlStage
|| card.control_stage
)
const riskDomain = resolveRiskDomain(card)
const actionability = resolveRiskActionability(card, { businessStage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(card, { businessStage, riskDomain, actionability })
return {
...card,
...(businessStage ? { businessStage } : {}),
riskDomain,
risk_domain: riskDomain,
actionability,
visibilityScope,
visibility_scope: visibilityScope,
tags: resolveRiskTags(card)
}
}
export function filterRiskCardsByBusinessStage(cards = [], businessStage = 'reimbursement') {
const targetStage = normalizeBusinessStage(businessStage) || 'reimbursement'
return (Array.isArray(cards) ? cards : []).filter(
(card) => resolveFlagBusinessStage(card, targetStage) === targetStage
)
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
}
@@ -286,21 +436,24 @@ function buildCardSuggestion(analysis, insight) {
)
}
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis, businessStage = 'reimbursement' }) {
const tone = normalizeTone(analysis?.severity)
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '风险')
const title = normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name) || '附件风险'
return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone,
label,
title: `${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
label: resolveRiskLevelLabel(tone),
title: `${index + 1} 条:${title}`,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight),
itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel)
documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
})
}
@@ -334,7 +487,7 @@ function resolveLatestManualReturnFlag(flags) {
}, manualReturnFlags[0])
}
function buildManualReturnRiskCard(flag) {
function buildManualReturnRiskCard(flag, businessStage = 'reimbursement') {
if (!flag) {
return null
}
@@ -355,21 +508,27 @@ function buildManualReturnRiskCard(flag) {
return withRiskTags({
id: `manual-return-${returnCount || 'latest'}`,
businessStage: resolveFlagBusinessStage(flag, normalizeBusinessStage(businessStage) || 'reimbursement'),
tone: 'medium',
label: '退回原因',
title: returnCount ? `${returnCount} 次退回` : '审批退回',
risk,
summary: normalizeText(flag.reason),
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。',
risk_domain: flag.risk_domain || flag.riskDomain || 'workflow',
visibility_scope: flag.visibility_scope || flag.visibilityScope || 'submitter',
actionability: flag.actionability || 'fixable_by_submitter'
})
}
export function buildAttachmentRiskCards({
expenseItems = [],
attachmentMetaByItemId = {},
claimRiskFlags = []
claimRiskFlags = [],
businessStage = 'reimbursement'
} = {}) {
const normalizedBusinessStage = normalizeBusinessStage(businessStage) || 'reimbursement'
const attachmentRiskItemIds = new Set()
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
@@ -393,17 +552,31 @@ export function buildAttachmentRiskCards({
: [analysis.summary || analysis.headline || analysis.label]
return points
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
.map((point, pointIndex) => buildRiskCardFromPoint({
item,
index,
point,
pointIndex,
insight,
analysis,
businessStage: normalizedBusinessStage
}))
.filter((card) => card.risk)
})
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
const latestManualReturnCard = buildManualReturnRiskCard(
resolveLatestManualReturnFlag(normalizedClaimRiskFlags),
normalizedBusinessStage
)
const claimCards = normalizedClaimRiskFlags
.flatMap((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return []
}
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
return []
}
if (!flag || typeof flag !== 'object') {
if (!isActionableRiskFlag(flag)) {
@@ -414,6 +587,7 @@ export function buildAttachmentRiskCards({
return risk
? [withRiskTags({
id: `claim-risk-${index}`,
businessStage: resolveRiskTextBusinessStage(risk, normalizedBusinessStage),
tone: 'medium',
label: '单据风险',
title: '单据风险提示',
@@ -457,13 +631,17 @@ export function buildAttachmentRiskCards({
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
tone,
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
title: normalizeText(flag.label) || '单据风险提示',
label: resolveRiskLevelLabel(tone),
title: normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
risk,
summary,
ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability
}))
})
.filter(Boolean)
@@ -504,11 +682,13 @@ export function buildClaimSummaryRiskCards(request = {}) {
if (!isRiskTone(tone)) {
return []
}
const businessStage = resolveRiskTextBusinessStage(summary, resolveRequestBusinessStage(request))
return [withRiskTags({
id: 'claim-risk-summary',
businessStage,
tone,
label: tone === 'high' ? '高风险' : '中风险',
label: resolveRiskLevelLabel(tone),
title: '单据风险提示',
risk: summary,
summary,
@@ -524,6 +704,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
const items = [
@@ -553,8 +734,9 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: '已知存在风险',
items: normalizedRiskCards
title: `已知存在风险${normalizedRiskCards.length}项)`,
items: sortedRiskCards,
totalCount: normalizedRiskCards.length
})
}
@@ -562,10 +744,25 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议`
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示`
: '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections
}
}
function sortRiskCardsByTone(cards) {
const toneWeight = {
high: 0,
medium: 1,
low: 2,
normal: 3,
pass: 4
}
return [...cards].sort((left, right) => {
const leftWeight = toneWeight[normalizeText(left?.tone).toLowerCase()] ?? 9
const rightWeight = toneWeight[normalizeText(right?.tone).toLowerCase()] ?? 9
return leftWeight - rightWeight
})
}

View File

@@ -0,0 +1,74 @@
export function isAiPreReviewFlag(flag) {
if (!flag || typeof flag !== 'object') {
return false
}
const source = String(flag.source || '').trim()
const eventType = String(flag.event_type || flag.eventType || '').trim()
return source === 'ai_pre_review' || eventType === 'expense_claim_ai_pre_review'
}
export function findLatestAiPreReviewEvent(flags = []) {
return flags
.filter(isAiPreReviewFlag)
.map((flag) => ({
...flag,
eventTime: new Date(flag.created_at || flag.createdAt || 0).getTime()
}))
.sort((left, right) => (left.eventTime || 0) - (right.eventTime || 0))
.pop() || null
}
export function buildAiPreReviewSnapshot(payload, fallbackClaimId = '') {
return {
claimId: String(payload?.id || fallbackClaimId || '').trim(),
riskFlags: Array.isArray(payload?.risk_flags_json) ? payload.risk_flags_json : []
}
}
export function isAiPreReviewPassed(event, requiresAiPreReview) {
if (!requiresAiPreReview) {
return true
}
return Boolean(event?.passed) || String(event?.status || '').trim() === 'passed'
}
export function resolveSubmitActionLabel({
isApplicationDocument,
hasAiPreReviewResult,
submitBusy
}) {
if (isApplicationDocument) {
return submitBusy ? '提交中' : '提交审批'
}
if (!hasAiPreReviewResult) {
return submitBusy ? '审核中' : 'AI审核'
}
return submitBusy ? '提交中' : '下一步'
}
export function resolveSubmitActionIcon({ isApplicationDocument, hasAiPreReviewResult }) {
if (isApplicationDocument) {
return 'mdi mdi-send-circle-outline'
}
return hasAiPreReviewResult ? 'mdi mdi-arrow-right-circle-outline' : 'mdi mdi-shield-check-outline'
}
export function resolveSubmitConfirmDescription({ isApplicationDocument, aiPreReviewPassed }) {
if (isApplicationDocument) {
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (!aiPreReviewPassed) {
return 'AI预审存在重大风险请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
}
return 'AI预审已完成请确认费用明细、附件材料和风险说明均已核对无误。确认后将进入审批流程。'
}
export function resolveSubmitConfirmText(isApplicationDocument) {
return isApplicationDocument ? '确认提交' : '确认下一步'
}
export function resolveAiPreReviewToast(event) {
return event && (event.passed || event.status === 'passed')
? 'AI预审通过请点击下一步提交审批。'
: 'AI预审发现重大风险请核对 AI建议 后再点击下一步。'
}

View File

@@ -4,8 +4,10 @@ import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
buildApplicationPreviewRows,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
normalizeApplicationPreview,
refreshApplicationPreviewTransportEstimate
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
@@ -33,10 +35,28 @@ function buildEmptyEditor() {
dateMode: 'single',
singleDate: getTodayDateValue(),
rangeStartDate: getTodayDateValue(),
rangeEndDate: getTodayDateValue()
rangeEndDate: getTodayDateValue(),
committing: false
}
}
function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
}
function buildTransportEstimatePendingPreview(preview = {}) {
const fields = preview?.fields || {}
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
transportPolicy: '正在查询交通参考票价...',
policyEstimate: '正在同步费用测算...',
transportEstimatedAmount: '查询中'
}
})
}
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
const applicationPreviewEditor = ref(buildEmptyEditor())
@@ -74,6 +94,7 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue,
committing: false,
...dateState
}
}
@@ -110,18 +131,29 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
})
}
function commitApplicationPreviewEditor(message) {
async function commitApplicationPreviewEditor(message) {
const editor = applicationPreviewEditor.value
if (editor.committing) {
return false
}
if (!message?.applicationPreview || String(editor.messageId || '') !== String(message.id || '') || !editor.fieldKey) {
cancelApplicationPreviewEditor()
return false
}
applicationPreviewEditor.value = {
...editor,
committing: true
}
const nextValue = editor.fieldKey === 'time'
? buildApplicationPreviewDateDraftValue()
: String(editor.draftValue || '').trim()
if (editor.fieldKey === 'time' && !nextValue) {
toast?.('请先选择有效日期。')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
committing: false
}
return false
}
const nextPreview = normalizeApplicationPreview({
@@ -131,15 +163,31 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
[editor.fieldKey]: nextValue
}
})
message.applicationPreview = nextPreview
message.text = buildLocalApplicationPreviewMessage(nextPreview)
const needRefreshTransport = shouldRefreshTransportEstimate(editor.fieldKey) && String(nextPreview.fields?.transportMode || '').trim()
message.applicationPreview = needRefreshTransport
? buildTransportEstimatePendingPreview(nextPreview)
: nextPreview
message.text = buildLocalApplicationPreviewMessage(message.applicationPreview)
cancelApplicationPreviewEditor()
persistSessionState?.()
if (needRefreshTransport) {
await waitForMockApplicationTransportQuote({
transportMode: nextPreview.fields.transportMode,
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
time: nextPreview.fields.time
})
const refreshedPreview = refreshApplicationPreviewTransportEstimate(nextPreview)
message.applicationPreview = refreshedPreview
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
persistSessionState?.()
toast?.('已更新出行方式和费用测算。')
return true
}
toast?.('已更新核对表内容。')
return true
}
function commitApplicationPreviewDateEditor(message) {
async function commitApplicationPreviewDateEditor(message) {
if (!canApplyApplicationPreviewDateSelection()) {
toast?.('请确认结束日期不早于开始日期。')
return false

View File

@@ -112,9 +112,12 @@ export function useAuditAssetData({
await loadAssets({ force: true, silent: true, background: true })
}
async function loadSelectedAssetDetail(assetId) {
detailLoading.value = true
detailError.value = ''
async function loadSelectedAssetDetail(assetId, options = {}) {
const silent = Boolean(options.silent)
if (!silent) {
detailLoading.value = true
detailError.value = ''
}
try {
if (!runs.value.length) {
@@ -155,10 +158,17 @@ export function useAuditAssetData({
}
}
} catch (error) {
detailError.value = error?.message || '资产详情加载失败,请稍后重试。'
toast(detailError.value)
const message = error?.message || '资产详情加载失败,请稍后重试。'
if (silent) {
console.warn('Silent asset detail refresh failed:', error)
} else {
detailError.value = message
toast(message)
}
} finally {
detailLoading.value = false
if (!silent) {
detailLoading.value = false
}
}
}

View File

@@ -5,13 +5,14 @@ import {
deleteAgentAsset,
fetchAgentAssetDetail,
publishRiskRuleAsset,
regenerateRiskRuleAsset,
returnRiskRuleAsset,
setRiskRuleAssetEnabled,
updateRiskRuleDraft
} from '../../services/agentAssets.js'
import { normalizeText } from './auditViewModel.js'
const DEFAULT_EXPENSE_CATEGORY = 'travel'
const DEFAULT_EXPENSE_CATEGORY = 'all'
export function useAuditRiskRuleActions({
selectedSkill,
@@ -127,16 +128,22 @@ export function useAuditRiskRuleActions({
}
actionState.value = 'save-risk-rule-edit'
const assetId = selectedSkill.value.id
try {
const detail = isRevision
? await createRiskRuleRevision(selectedSkill.value.id, payload, { actor: resolveActor() })
: await updateRiskRuleDraft(selectedSkill.value.id, payload, { actor: resolveActor() })
const actor = resolveActor()
if (isRevision) {
await createRiskRuleRevision(assetId, payload, { actor })
} else {
await updateRiskRuleDraft(assetId, payload, { actor })
}
const regenerated = await regenerateRiskRuleAsset(assetId, buildRegeneratePayload(payload), { actor })
riskRuleEditOpen.value = false
mergeSelectedRuleLifecycle(detail)
await refreshCurrentAssets()
toast(isRevision ? '已创建风险规则修订草稿。' : '风险规则草稿已更新。')
mergeSelectedRuleLifecycle(regenerated)
await loadSelectedAssetDetail(assetId, { silent: true })
toast(isRevision ? '已创建修订草稿并重新生成规则。' : '风险规则草稿已保存并重新生成。')
} catch (error) {
toast(error?.message || (isRevision ? '创建修订版本失败,请稍后重试。' : '编辑规则草稿失败,请稍后重试。'))
toast(error?.message || (isRevision ? '创建并生成修订版本失败,请稍后重试。' : '保存并生成规则草稿失败,请稍后重试。'))
} finally {
actionState.value = ''
}
@@ -207,7 +214,7 @@ export function useAuditRiskRuleActions({
await returnRiskRuleAsset(selectedSkill.value.id, { note }, { actor: resolveActor() })
riskRuleReturnOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast('风险规则已回退到草稿。')
} catch (error) {
toast(error?.message || '风险规则回退失败,请稍后重试。')
@@ -243,7 +250,7 @@ export function useAuditRiskRuleActions({
await publishRiskRuleAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRulePublishOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast('风险规则已发布上线。')
} catch (error) {
toast(error?.message || '风险规则发布失败,请稍后重试。')
@@ -328,3 +335,12 @@ function normalizeRiskRuleEditPayload(form, includeReason) {
}
return payload
}
function buildRegeneratePayload(payload) {
return {
rule_title: payload.rule_title,
expense_category: payload.expense_category,
requires_attachment: payload.requires_attachment,
natural_language: payload.natural_language
}
}

View File

@@ -60,7 +60,7 @@ export function useAuditRiskRuleCreateFlow({
try {
const detail = await generateRiskRuleAsset(
{
business_domain: 'expense',
business_domain: riskRuleCreateForm.value.business_domain || 'expense',
business_stage: riskRuleCreateForm.value.business_stage,
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,

View File

@@ -68,7 +68,7 @@ export function useAuditRuleReviewFlow({
{ actor: resolveActor() }
)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`当前规则版本已标记为${resolveReviewMeta(reviewStatus).label}`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')
@@ -161,7 +161,7 @@ export function useAuditRuleReviewFlow({
)
reviewSubmitOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`规则版本 ${version} 已提交给 ${reviewer} 审核。`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')

View File

@@ -75,7 +75,7 @@ export function useAuditRuleVersionActions({
)
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`${successLabel} ${nextVersion}`)
} catch (error) {
toast(error?.message || `${successLabel}失败,请稍后重试。`)
@@ -109,7 +109,7 @@ export function useAuditRuleVersionActions({
try {
await activateAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast('规则已正式上线。')
} catch (error) {
toast(error?.message || '规则上线失败,请稍后重试。')
@@ -133,7 +133,7 @@ export function useAuditRuleVersionActions({
try {
await restoreAgentAssetVersion(selectedSkill.value.id, version, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`已基于 ${version} 生成新的工作版本。`)
} catch (error) {
toast(error?.message || '历史版本恢复失败,请稍后重试。')

View File

@@ -101,6 +101,7 @@ export function useTravelReimbursementGuidedFlow({
lockSuggestedActionMessage,
submitExistingComposer,
currentUser,
refreshCurrentUserFromBackend,
toast
}) {
const guidedPendingFiles = ref([])
@@ -151,9 +152,19 @@ export function useTravelReimbursementGuidedFlow({
persistAndScroll()
}
function startGuidedApplicationTemplate() {
async function resolveApplicationPreviewUser() {
const user = currentUser?.value || {}
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
return user
}
await refreshCurrentUserFromBackend({ silent: true })
return currentUser?.value || user
}
async function startGuidedApplicationTemplate() {
resetGuidedFlowState()
const applicationPreview = buildApplicationTemplatePreview(currentUser?.value || {})
const applicationPreview = buildApplicationTemplatePreview(await resolveApplicationPreviewUser())
pushAssistant(buildLocalApplicationPreviewMessage(applicationPreview), {
meta: ['申请模板'],
applicationPreview
@@ -171,10 +182,10 @@ export function useTravelReimbursementGuidedFlow({
persistAndScroll()
}
function handleGuidedShortcut(shortcut) {
async function handleGuidedShortcut(shortcut) {
const actionType = normalizeText(shortcut?.action)
if (actionType === GUIDED_ACTION_START_APPLICATION) {
startGuidedApplicationTemplate()
await startGuidedApplicationTemplate()
return true
}
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
@@ -245,6 +256,7 @@ export function useTravelReimbursementGuidedFlow({
claimsPayload = await fetchExpenseClaims()
} catch (error) {
console.warn('Fetch reimbursement applications failed:', error)
guidedFlowState.value = createEmptyGuidedFlowState()
pushAssistant('查询可关联申请单时出现异常,请稍后再试。为避免直接报销,我先暂停当前流程。', {
meta: ['申请单查询失败']
})
@@ -254,10 +266,9 @@ export function useTravelReimbursementGuidedFlow({
const applications = filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser?.value || {})
if (!applications.length) {
guidedFlowState.value = createGuidedReimbursementState()
guidedFlowState.value = createEmptyGuidedFlowState()
pushAssistant(buildRequiredApplicationMissingText(expenseType), {
meta: ['缺少可关联申请单'],
suggestedActions: buildGuidedExpenseTypeActions()
meta: ['缺少可关联申请单']
})
return
}

View File

@@ -13,6 +13,7 @@ import {
buildModelRefinedApplicationPreview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
@@ -79,6 +80,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
persistSessionState,
props,
recognizeOcrFiles,
refreshCurrentUserFromBackend,
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
@@ -339,8 +341,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
]
}
async function buildApplicationPreviewWithModelReview(rawText) {
async function resolveApplicationPreviewUser() {
const user = currentUser.value || {}
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
return user
}
await refreshCurrentUserFromBackend({ silent: true })
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText) {
const user = await resolveApplicationPreviewUser()
const localPreview = buildLocalApplicationPreview(rawText, user)
const enrichWithPolicyEstimate = async (preview) => {
@@ -349,6 +361,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return preview
}
try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
@@ -548,14 +566,14 @@ export function useTravelReimbursementSubmitComposer(ctx) {
startFlowStep('application-review-preview', {
title: '申请信息核对',
tool: 'ontology.application_review',
detail: '正在进行申请信息模型复核...'
detail: '正在复核申请信息,并查询交通票价...'
})
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingMessage = createMessage(
'assistant',
'正在进行申请信息模型复核。本步骤只识别意图和抽取字段,不会创建、更新或保存草稿。',
'正在复核申请信息,并查询交通票价,请稍候。',
[],
{
meta: ['模型复核中']
@@ -770,7 +788,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
isKnowledgeSession.value
? '正在整理财务知识答案...'
: activeSessionType.value === 'application'
? '正在识别并整理申请核对信息...'
? '正在识别申请信息并查询交通票价...'
: activeSessionType.value === 'approval'
? '正在查询审核上下文并整理风险提示...'
: '正在识别并整理右侧核对信息...'
@@ -1037,20 +1055,29 @@ export function useTravelReimbursementSubmitComposer(ctx) {
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
.then((syncResult) => {
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
persistSessionState()
if (detailScopedUpload && Number(syncResult?.uploadedCount || 0) > 0) {
if (detailScopedUpload) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync'
source: 'detail-smart-entry-attachment-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
}
})
.catch((error) => {
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
})
}
}
const persistTask = persistComposerFilesToDraft()
if (detailScopedUpload) {
await persistTask
} else {
void persistTask
}
}
} catch (error) {
clearFlowSimulationTimers()