feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
568
web/src/assets/styles/components/employee-profile-risk-card.css
Normal file
568
web/src/assets/styles/components/employee-profile-risk-card.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
496
web/src/assets/styles/views/agent-trace-center-view.css
Normal file
496
web/src/assets/styles/views/agent-trace-center-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '重复发票',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
web/src/components/charts/DigitalEmployeeDailyWorkChart.vue
Normal file
154
web/src/components/charts/DigitalEmployeeDailyWorkChart.vue
Normal 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>
|
||||
515
web/src/components/dashboard/DigitalEmployeeDashboard.vue
Normal file
515
web/src/components/dashboard/DigitalEmployeeDashboard.vue
Normal 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>
|
||||
@@ -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) }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
722
web/src/components/travel/StageRiskAdviceCard.vue
Normal file
722
web/src/components/travel/StageRiskAdviceCard.vue
Normal 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>
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
39
web/src/services/agentTraces.js
Normal file
39
web/src/services/agentTraces.js
Normal 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())}`)
|
||||
}
|
||||
@@ -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日'))
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
125
web/src/utils/agentTraceViewModel.js
Normal file
125
web/src/utils/agentTraceViewModel.js
Normal 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
70
web/src/utils/authUser.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
180
web/src/utils/expenseApplicationEstimate.js
Normal file
180
web/src/utils/expenseApplicationEstimate.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
316
web/src/utils/riskVisibility.js
Normal file
316
web/src/utils/riskVisibility.js
Normal 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(' ')
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
336
web/src/views/AgentTraceCenterView.vue
Normal file
336
web/src/views/AgentTraceCenterView.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="保存中..."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '语义解析失败,请稍后重试。'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '交通费' },
|
||||
|
||||
@@ -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: ''
|
||||
|
||||
@@ -42,6 +42,7 @@ const LAST_OPERATION_LABELS = {
|
||||
test: '测试',
|
||||
online: '上线',
|
||||
offline: '下线',
|
||||
generation_failed: '生成失败',
|
||||
delete: '删除',
|
||||
update: '更新'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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% 部门机动池。`,
|
||||
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
|
||||
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
|
||||
],
|
||||
|
||||
365
web/src/views/scripts/budgetCenterListModel.js
Normal file
365
web/src/views/scripts/budgetCenterListModel.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
131
web/src/views/scripts/overviewDigitalEmployeeDashboardModel.js
Normal file
131
web/src/views/scripts/overviewDigitalEmployeeDashboardModel.js
Normal 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)
|
||||
}))
|
||||
}
|
||||
124
web/src/views/scripts/receiptFolderDetailDashboard.js
Normal file
124
web/src/views/scripts/receiptFolderDetailDashboard.js
Normal 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) : '待确认'
|
||||
}
|
||||
@@ -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: '乘车票据提醒',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
74
web/src/views/scripts/travelRequestDetailPreReviewModel.js
Normal file
74
web/src/views/scripts/travelRequestDetailPreReviewModel.js
Normal 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建议 后再点击下一步。'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '规则审核提交失败,请稍后重试。')
|
||||
|
||||
@@ -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 || '历史版本恢复失败,请稍后重试。')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -40,7 +40,9 @@ test('direct approvers can return claims without receiving delete permissions',
|
||||
false
|
||||
)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: [], grade: 'P8' }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'], grade: 'P7' }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'], grade: 'P8' }), true)
|
||||
assert.equal(canManageExpenseClaims(managerUser), false)
|
||||
assert.equal(canManageExpenseClaims(approverUser), false)
|
||||
})
|
||||
@@ -101,10 +103,11 @@ test('finance approval inbox only processes finance-stage requests', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('budget approval inbox only processes budget-stage requests for budget monitor or senior finance roles', () => {
|
||||
test('budget approval inbox only processes budget-stage requests for department P8 budget approvers', () => {
|
||||
const budgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '赵预算', departmentName: '交付部' }
|
||||
const otherDepartmentBudgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '王预算', departmentName: '财务部' }
|
||||
const seniorFinanceUser = { roleCodes: ['executive'], grade: 'P7', name: '高级财务' }
|
||||
const p8ExecutiveBudgetUser = { roleCodes: ['executive'], grade: 'P8', name: 'P8 Executive', departmentName: '交付部' }
|
||||
const p8WithoutBudgetRole = { roleCodes: ['manager'], grade: 'P8', name: '高职级经理' }
|
||||
|
||||
assert.equal(
|
||||
@@ -113,6 +116,10 @@ test('budget approval inbox only processes budget-stage requests for budget moni
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, seniorFinanceUser),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, p8ExecutiveBudgetUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
@@ -170,3 +177,35 @@ test('direct-manager approval helpers only match claims pushed to the current us
|
||||
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '李经理' }, managerUser), true)
|
||||
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false)
|
||||
})
|
||||
|
||||
test('applicant helper matches generated draft owner by employee identifiers', () => {
|
||||
const currentUser = {
|
||||
username: 'caoxiaozhu@xf.com',
|
||||
email: 'caoxiaozhu@xf.com',
|
||||
employeeNo: 'E90919',
|
||||
name: '曹笑竹'
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
isCurrentRequestApplicant(
|
||||
{
|
||||
employeeNo: 'E90919',
|
||||
employeeName: '曹笑竹',
|
||||
person: '曹笑竹'
|
||||
},
|
||||
currentUser
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isCurrentRequestApplicant(
|
||||
{
|
||||
employeeNo: 'E10001',
|
||||
employeeName: '张三',
|
||||
person: '张三'
|
||||
},
|
||||
currentUser
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
@@ -52,6 +52,10 @@ async function testInjectsAuthenticatedUserHeaders() {
|
||||
JSON.stringify({
|
||||
username: 'admin',
|
||||
name: 'Admin User',
|
||||
employeePosition: 'System Manager',
|
||||
employeeGrade: 'M5',
|
||||
employeeNo: 'E-001',
|
||||
managerName: 'Approver User',
|
||||
roleCodes: ['manager'],
|
||||
isAdmin: true
|
||||
})
|
||||
@@ -82,6 +86,10 @@ async function testInjectsAuthenticatedUserHeaders() {
|
||||
|
||||
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
|
||||
assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User')
|
||||
assert.equal(capturedOptions.headers['x-auth-position'], 'System Manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-grade'], 'M5')
|
||||
assert.equal(capturedOptions.headers['x-auth-employee-no'], 'E-001')
|
||||
assert.equal(capturedOptions.headers['x-auth-manager-name'], 'Approver User')
|
||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||
}
|
||||
|
||||
@@ -125,6 +125,82 @@ test('application detail topbar does not ask for receipt attachments', () => {
|
||||
assert.deepEqual(alerts, ['SLA 催单次数 0'])
|
||||
})
|
||||
|
||||
test('detail topbar surfaces stored medium and high risk flags first', () => {
|
||||
const highAlerts = buildDetailAlerts({
|
||||
node: 'AI预审',
|
||||
approvalKey: 'draft',
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
hit_source: 'rule_center',
|
||||
severity: 'high',
|
||||
message: '票据日期超出申报差旅行程。'
|
||||
},
|
||||
{
|
||||
source: 'submission_review',
|
||||
hit_source: 'rule_center',
|
||||
severity: 'medium',
|
||||
message: '票据城市需要人工核对。'
|
||||
}
|
||||
],
|
||||
expenseItems: []
|
||||
})
|
||||
const mediumAlerts = buildDetailAlerts({
|
||||
node: 'AI预审',
|
||||
approvalKey: 'draft',
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
hit_source: 'rule_center',
|
||||
severity: 'medium',
|
||||
message: '票据城市需要人工核对。'
|
||||
}
|
||||
],
|
||||
expenseItems: []
|
||||
})
|
||||
|
||||
assert.equal(highAlerts[0].label, '高风险 1 项')
|
||||
assert.equal(highAlerts[0].tone, 'danger')
|
||||
assert.equal(mediumAlerts[0].label, '中风险 1 项')
|
||||
assert.equal(mediumAlerts[0].tone, 'warning')
|
||||
})
|
||||
|
||||
test('detail topbar does not treat handoff or SLA events as risk flags', () => {
|
||||
const alerts = buildDetailAlerts({
|
||||
node: '待提交',
|
||||
approvalKey: 'draft',
|
||||
typeCode: 'travel',
|
||||
typeLabel: '差旅费',
|
||||
reason: '上海项目出差',
|
||||
location: '上海',
|
||||
city: '上海',
|
||||
occurredDisplay: '2026-05-13',
|
||||
amountValue: 300,
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'application_handoff',
|
||||
event_type: 'expense_application_to_reimbursement_draft',
|
||||
severity: 'info',
|
||||
message: '费用申请已生成报销草稿。'
|
||||
},
|
||||
{ source: 'sla_reminder', message: '下属已催单' }
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'train',
|
||||
itemType: 'train_ticket',
|
||||
itemReason: '武汉-上海',
|
||||
itemLocation: '上海',
|
||||
itemDate: '2026-05-13',
|
||||
itemAmount: 300,
|
||||
invoiceId: 'ticket.pdf'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(alerts.map((item) => item.label), ['SLA 催单次数 1'])
|
||||
})
|
||||
|
||||
test('detail topbar shows SLA reminder count from direct fields and reminder events', () => {
|
||||
const directAlerts = buildDetailAlerts({
|
||||
node: '直属领导审批',
|
||||
|
||||
91
web/tests/digital-employee-dashboard.test.mjs
Normal file
91
web/tests/digital-employee-dashboard.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeDigitalEmployeeDashboardPayload } from '../src/services/analytics.js'
|
||||
|
||||
const topBar = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const analyticsService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/analytics.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const dashboardComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/dashboard/DigitalEmployeeDashboard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const dailyChartComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/charts/DigitalEmployeeDailyWorkChart.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const dashboardModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/overviewDigitalEmployeeDashboardModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('digital employee dashboard normalizes backend payload fields', () => {
|
||||
const dashboard = normalizeDigitalEmployeeDashboardPayload({
|
||||
window_days: 7,
|
||||
generated_at: '2026-06-01T08:00:00Z',
|
||||
has_real_data: true,
|
||||
totals: {
|
||||
totalRuns: 3,
|
||||
successRuns: 2,
|
||||
failedRuns: 1,
|
||||
businessOutputs: 8
|
||||
},
|
||||
daily_work: [{ date: '06-01', total: 3, businessOutputs: 8 }],
|
||||
task_distribution: [{ name: '知识制度整理', count: 2 }],
|
||||
category_distribution: [{ name: '整理', count: 2 }],
|
||||
recent_runs: [{ runId: 'run-001', taskLabel: '知识制度整理' }]
|
||||
})
|
||||
|
||||
assert.equal(dashboard.windowDays, 7)
|
||||
assert.equal(dashboard.generatedAt, '2026-06-01T08:00:00Z')
|
||||
assert.equal(dashboard.hasRealData, true)
|
||||
assert.equal(dashboard.totals.totalRuns, 3)
|
||||
assert.equal(dashboard.dailyWork[0].businessOutputs, 8)
|
||||
assert.equal(dashboard.taskDistribution[0].name, '知识制度整理')
|
||||
assert.equal(dashboard.categoryDistribution[0].name, '整理')
|
||||
assert.equal(dashboard.recentRuns[0].runId, 'run-001')
|
||||
})
|
||||
|
||||
test('digital employee dashboard is wired into overview dashboard switch', () => {
|
||||
assert.match(topBar, /label: '数字员工看板', value: 'digitalEmployee'/)
|
||||
assert.match(overviewView, /<DigitalEmployeeDashboard/)
|
||||
assert.match(overviewView, /activeDashboard === 'digitalEmployee'/)
|
||||
assert.match(overviewView, /digitalEmployeeKpiMetrics/)
|
||||
assert.match(overviewViewModel, /fetchDigitalEmployeeDashboard/)
|
||||
assert.match(overviewViewModel, /const digitalEmployeeKpiMetrics = computed/)
|
||||
assert.match(dashboardModel, /label: '工作总数'/)
|
||||
assert.match(dashboardModel, /label: '成功数量'/)
|
||||
assert.match(dashboardModel, /label: '失败数量'/)
|
||||
assert.match(dashboardModel, /categoryDistribution/)
|
||||
assert.match(analyticsService, /\/analytics\/digital-employee-dashboard/)
|
||||
})
|
||||
|
||||
test('digital employee dashboard renders enterprise dashboard panels with chart components', () => {
|
||||
assert.match(dashboardComponent, /每日工作趋势/)
|
||||
assert.match(dashboardComponent, /每日工作摘要/)
|
||||
assert.match(dashboardComponent, /技能类型分布/)
|
||||
assert.match(dashboardComponent, /工作模块排行/)
|
||||
assert.match(dashboardComponent, /最近工作记录/)
|
||||
assert.match(dashboardComponent, /DigitalEmployeeDailyWorkChart/)
|
||||
assert.match(dashboardComponent, /DonutChart/)
|
||||
assert.match(dashboardComponent, /BarChart/)
|
||||
assert.match(dailyChartComponent, /echarts\/charts/)
|
||||
assert.match(dailyChartComponent, /name: '工作次数'/)
|
||||
assert.match(dailyChartComponent, /name: '业务产出'/)
|
||||
assert.doesNotMatch(dashboardComponent, /hermes/i)
|
||||
})
|
||||
@@ -69,10 +69,43 @@ test('digital employee profile run resolves from tool request when route is spar
|
||||
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
|
||||
})
|
||||
|
||||
test('digital employee risk clue run resolves review packet metadata', () => {
|
||||
const run = {
|
||||
route_json: {
|
||||
task_code: 'task.hermes.risk_rule_discovery',
|
||||
task_name: '风险线索归集'
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'digital_employee.risk_clue.collect',
|
||||
request_json: { task_type: 'risk_clue_collect' },
|
||||
response_json: {
|
||||
fact_count: 5,
|
||||
rule_hit_count: 3,
|
||||
risk_clue_count: 2,
|
||||
risk_clues: [{ clue_id: 'risk_clue:1', title: '待复核线索' }],
|
||||
feedback_summary: {
|
||||
total: 1,
|
||||
recent: [{ feedback_id: 'fb-1', feedback_type: 'comment', observation_key: 'risk:c1' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert.equal(resolveWorkRecordTaskType(run), 'risk_clue_collect')
|
||||
assert.equal(resolveWorkRecordProductKind(run), 'risk_clue')
|
||||
assert.equal(resolveWorkRecordModuleLabel(run), '风险线索归集')
|
||||
assert.equal(extractWorkRecordToolSummary(run).risk_clue_count, 2)
|
||||
})
|
||||
|
||||
test('digital employee work record product supports scoped observation expansion', () => {
|
||||
assert.match(runProductsComponent, /activeObservationKey/)
|
||||
assert.match(runProductsComponent, /toggleObservation/)
|
||||
assert.match(runProductsComponent, /异常关系/)
|
||||
assert.match(runProductsComponent, /待复核线索/)
|
||||
assert.match(runProductsComponent, /反馈样本/)
|
||||
assert.match(runProductsComponent, /formatFeedbackStatus/)
|
||||
assert.doesNotMatch(runProductsComponent, /KnowledgeIngestGraphView/)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatDocumentListTime,
|
||||
formatDocumentDurationSince,
|
||||
resolveDocumentStayTimeDisplay
|
||||
} from '../src/utils/documentCenterTime.js'
|
||||
|
||||
test('document center list time keeps the full year in created time', () => {
|
||||
assert.equal(formatDocumentListTime('2026-05-20T09:30:00'), '2026-05-20 09:30')
|
||||
assert.equal(formatDocumentListTime('2026-05-20 创建成功'), '2026-05-20 创建成功')
|
||||
})
|
||||
|
||||
test('document center stay time uses current workflow step stay label first', () => {
|
||||
const label = resolveDocumentStayTimeDisplay({
|
||||
progressSteps: [
|
||||
|
||||
@@ -17,12 +17,18 @@ import {
|
||||
normalizeApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildMockApplicationTransportEstimate,
|
||||
resolveMockApplicationTransportWaitMs,
|
||||
buildSystemApplicationEstimate
|
||||
} from '../src/utils/expenseApplicationEstimate.js'
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
import {
|
||||
createMessage as createConversationMessage,
|
||||
hasMeaningfulSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
@@ -110,14 +116,24 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
false
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', grade: 'P5' })
|
||||
const preview = buildLocalApplicationPreview(prompt, {
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.fields.amount, '2358元')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.position, '财务分析师')
|
||||
assert.equal(preview.fields.managerName, '王强')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
|
||||
@@ -127,6 +143,9 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.location, '新疆,伊犁')
|
||||
@@ -143,14 +162,62 @@ test('application preview renders ordered editable rows and submit text uses edi
|
||||
const rows = buildApplicationPreviewRows(editedPreview)
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.label),
|
||||
['申请类型', '职级', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '用户预估费用']
|
||||
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
||||
)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'applicant')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'grade')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'department')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'position')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'managerName')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /姓名:李文静/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /部门:财务部/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /岗位:财务分析师/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /直属领导:王强/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用:1900元/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用:1900元/)
|
||||
})
|
||||
|
||||
test('application estimate builds deterministic mock transport amount and total', () => {
|
||||
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
|
||||
const datedTrainEstimate = buildMockApplicationTransportEstimate({
|
||||
transportMode: '高铁',
|
||||
location: '上海',
|
||||
time: '2026-05-25 至 2026-05-28'
|
||||
})
|
||||
const flightEstimate = buildMockApplicationTransportEstimate({ transportMode: '机票', location: '新疆,伊犁' })
|
||||
const shipEstimate = buildMockApplicationTransportEstimate({ transportMode: '船票', location: '厦门' })
|
||||
const totalEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: '火车',
|
||||
location: '上海',
|
||||
lodgingAmount: 1800,
|
||||
allowanceAmount: 360
|
||||
})
|
||||
const datedTotalEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: '火车',
|
||||
location: '上海',
|
||||
time: '2026-05-25 至 2026-05-28',
|
||||
lodgingAmount: 1800,
|
||||
allowanceAmount: 360
|
||||
})
|
||||
|
||||
assert.equal(trainEstimate.amountDisplay, '1,040')
|
||||
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
||||
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
||||
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
|
||||
assert.match(datedTrainEstimate.basisText, /查询耗时 \d+ms/)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
||||
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
||||
assert.equal(flightEstimate.amountDisplay, '3,600')
|
||||
assert.equal(shipEstimate.amountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
|
||||
assert.equal(datedTotalEstimate.transportAmountDisplay, '1,100')
|
||||
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
|
||||
})
|
||||
|
||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||
@@ -258,6 +325,8 @@ test('application quick start renders a template without model review', () => {
|
||||
const preview = buildApplicationTemplatePreview({
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
const message = buildLocalApplicationPreviewMessage(preview)
|
||||
@@ -266,6 +335,8 @@ test('application quick start renders a template without model review', () => {
|
||||
assert.equal(preview.fields.applicationType, '费用申请')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.position, '财务分析师')
|
||||
assert.equal(preview.fields.managerName, '王强')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.equal(buildApplicationPreviewRows(preview).find((row) => row.key === 'grade')?.editable, false)
|
||||
assert.match(message, /不调用大模型/)
|
||||
@@ -389,7 +460,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/)
|
||||
assert.doesNotMatch(applicationMessageStyles, /\.application-date-editor-layer/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-preview \.application-draft-head \{[\s\S]*grid-template-columns: 36px minmax\(0, 1fr\) auto;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*border: 1px solid #d7e4f2;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*gap: 1px;[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #d7e4f2;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/)
|
||||
assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
|
||||
|
||||
assert.match(flowScript, /application-submit-success/)
|
||||
@@ -490,7 +563,64 @@ test('application preview merges rule center travel estimate into highlighted ro
|
||||
|
||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /实报实销/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /2,160元/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /参考票价/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /2026-05-25/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
||||
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
|
||||
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
|
||||
assert.equal(estimatedPreview.fields.amount, '3,260元')
|
||||
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
|
||||
})
|
||||
|
||||
test('application preview editor refreshes transport estimate after mode change', async () => {
|
||||
const preview = applyApplicationPolicyEstimateResult(
|
||||
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天,服务项目部署', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
}),
|
||||
{
|
||||
days: 3,
|
||||
location: '上海',
|
||||
matched_city: '上海',
|
||||
grade: 'P5',
|
||||
hotel_rate: 600,
|
||||
hotel_amount: 1800,
|
||||
total_allowance_rate: 120,
|
||||
allowance_amount: 360,
|
||||
total_amount: 2160
|
||||
},
|
||||
{ grade: 'P5' }
|
||||
)
|
||||
const message = {
|
||||
id: 'application-preview-editor-message',
|
||||
applicationPreview: preview,
|
||||
text: ''
|
||||
}
|
||||
let persistCount = 0
|
||||
const toastMessages = []
|
||||
const editor = useApplicationPreviewEditor({
|
||||
persistSessionState: () => {
|
||||
persistCount += 1
|
||||
},
|
||||
toast: (messageText) => {
|
||||
toastMessages.push(messageText)
|
||||
}
|
||||
})
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'transportMode', '待补充')
|
||||
editor.applicationPreviewEditor.value.draftValue = '飞机'
|
||||
const committed = await editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
assert.equal(committed, true)
|
||||
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
||||
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /已查询 2026-05-25 飞机参考票价/)
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||||
assert.ok(persistCount >= 2)
|
||||
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
||||
})
|
||||
|
||||
@@ -11,26 +11,33 @@ function readProjectFile(path) {
|
||||
function testReceiptFolderViewSurface() {
|
||||
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
|
||||
assert.match(view, /未关联票据/)
|
||||
assert.match(view, /已关联票据/)
|
||||
assert.match(view, /label: '全部'/)
|
||||
assert.match(view, /一键关联票据/)
|
||||
assert.match(view, /票据关键字段/)
|
||||
assert.match(view, /原始文件/)
|
||||
assert.match(view, /activeStatus = ref\('all'\)/)
|
||||
assert.match(view, /value: 'all'/)
|
||||
assert.match(view, /openAssociateDialog/)
|
||||
assert.match(view, /receipt-detail-toolbar/)
|
||||
assert.match(view, /receipt-dashboard/)
|
||||
assert.match(view, /receipt-dashboard-preview/)
|
||||
assert.match(view, /receipt-dashboard-side/)
|
||||
assert.match(view, /receipt-dashboard-bottom/)
|
||||
assert.match(view, /receipt-ocr-panel/)
|
||||
assert.match(view, /receipt-status-panel/)
|
||||
assert.match(view, /keyReceiptFields/)
|
||||
assert.match(view, /editableOtherFields/)
|
||||
assert.match(view, /ocrPreviewFields/)
|
||||
assert.match(view, /class="receipt-key-grid"/)
|
||||
assert.match(view, /class="receipt-other-collapse"/)
|
||||
assert.match(view, /class="receipt-other-scroll"/)
|
||||
assert.match(view, /其他信息/)
|
||||
assert.match(view, /previewTransform/)
|
||||
assert.match(view, /openAssociateDialogForCurrentReceipt/)
|
||||
assert.match(view, /createReceiptDetailDashboardModel/)
|
||||
assert.match(view, /ElCollapse/)
|
||||
assert.doesNotMatch(view, /新增字段/)
|
||||
assert.doesNotMatch(view, /addField/)
|
||||
assert.match(view, /const isTrainTicket = computed/)
|
||||
assert.doesNotMatch(view, /打开源文件/)
|
||||
assert.doesNotMatch(view, /openSourceFile/)
|
||||
assert.match(view, /返回票据夹/)
|
||||
assert.doesNotMatch(view, /返回列表/)
|
||||
assert.match(view, /删除票据/)
|
||||
assert.match(view, /back-label=/)
|
||||
assert.doesNotMatch(view, /back-btn/)
|
||||
assert.match(view, /deleteCurrentReceipt/)
|
||||
assert.match(view, /ElCheckboxGroup/)
|
||||
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
|
||||
assert.match(view, /buildReceiptFile\(item\)/)
|
||||
@@ -90,8 +97,7 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
assert.match(receiptView, /showStatusColumn/)
|
||||
assert.match(receiptView, /<col v-if="showStatusColumn" class="col-status">/)
|
||||
assert.match(receiptView, /<th v-if="showStatusColumn">/)
|
||||
assert.match(receiptView, /<th>票据日期<\/th>/)
|
||||
assert.match(receiptView, /<td>{{ row\.document_date \|\| '待补充' }}<\/td>/)
|
||||
assert.match(receiptView, /document_date/)
|
||||
assert.match(receiptView, /<td>\s*<strong class="doc-id">/)
|
||||
assert.match(receiptView, /<td v-if="showStatusColumn">\s*<span class="status-tag"/)
|
||||
assert.match(receiptView, /const activeStatus = ref\('all'\)/)
|
||||
@@ -100,12 +106,14 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
assert.match(receiptView, /<EnterpriseDetailPage/)
|
||||
assert.match(receiptView, /variant="receipt-folder-detail"/)
|
||||
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-basic-panel"/)
|
||||
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-preview-panel"/)
|
||||
assert.match(receiptView, /receipt-dashboard-preview/)
|
||||
assert.match(receiptView, /receipt-dashboard-bottom/)
|
||||
assert.match(receiptView, /createReceiptDetailFieldModel/)
|
||||
assert.match(receiptView, /createReceiptDetailDashboardModel/)
|
||||
assert.match(receiptView, /buildDetailPayload\(\)/)
|
||||
assert.match(receiptView, /receiptDetailSubtitle/)
|
||||
assert.match(receiptView, /receiptDetailTopBarPayload/)
|
||||
assert.match(receiptView, /eyebrow: '票据详情'/)
|
||||
assert.match(receiptView, /eyebrow:/)
|
||||
assert.match(receiptView, /detail-topbar-change/)
|
||||
assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/)
|
||||
assert.doesNotMatch(receiptView, /class="back-btn"/)
|
||||
@@ -115,6 +123,11 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-grid\)/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-actions\)/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/)
|
||||
assert.match(receiptStyles, /\.receipt-detail-toolbar/)
|
||||
assert.match(receiptStyles, /\.receipt-dashboard/)
|
||||
assert.match(receiptStyles, /\.receipt-dashboard-bottom/)
|
||||
assert.match(receiptStyles, /\.receipt-preview-tools/)
|
||||
assert.match(receiptStyles, /\.receipt-log-list/)
|
||||
assert.match(receiptStyles, /\.receipt-key-grid/)
|
||||
assert.match(receiptStyles, /\.receipt-other-collapse/)
|
||||
assert.match(receiptStyles, /\.receipt-other-scroll/)
|
||||
@@ -124,11 +137,16 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
assert.doesNotMatch(receiptStyles, /\.back-btn\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.danger-btn\b/)
|
||||
assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/)
|
||||
assert.match(fieldModel, /label: '发票号码'/)
|
||||
assert.match(fieldModel, /label: '开票日期'/)
|
||||
assert.match(fieldModel, /label: '票价'/)
|
||||
assert.match(fieldModel, /label: '姓名'/)
|
||||
assert.match(fieldModel, /id: 'invoice_number'/)
|
||||
assert.match(fieldModel, /id: 'invoice_date'/)
|
||||
assert.match(fieldModel, /id: 'fare'/)
|
||||
assert.match(fieldModel, /id: 'passenger_name'/)
|
||||
assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
|
||||
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
|
||||
assert.match(dashboardModel, /createReceiptDetailDashboardModel/)
|
||||
assert.match(dashboardModel, /basicInfoItems/)
|
||||
assert.match(dashboardModel, /operationLogs/)
|
||||
assert.match(dashboardModel, /archiveInfoItems/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
|
||||
@@ -14,8 +14,62 @@ const PAID = '\u5df2\u4ed8\u6b3e'
|
||||
const ARCHIVED = '\u5df2\u5f52\u6863'
|
||||
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
|
||||
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
|
||||
const WAIT_BUDGET_P8_EXECUTIVE_APPROVAL = '\u7b49\u5f85 P8 Executive \u6279\u590d'
|
||||
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
|
||||
|
||||
test('claim mapper exposes employee identifier for reviewer risk profile lookup', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-profile-1',
|
||||
claim_no: 'EXP-PROFILE-1',
|
||||
employee_id: 'emp-profile-1',
|
||||
employee_name: 'Alice',
|
||||
department_name: 'Finance',
|
||||
expense_type: 'travel',
|
||||
reason: 'Trip',
|
||||
location: 'Shanghai',
|
||||
amount: 1200,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T02:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
risk_flags_json: [],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.employeeId, 'emp-profile-1')
|
||||
assert.equal(request.employee_id, 'emp-profile-1')
|
||||
assert.equal(request.profileEmployeeId, 'emp-profile-1')
|
||||
})
|
||||
|
||||
test('claim mapper falls back to employee name for legacy profile lookup', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-profile-legacy',
|
||||
claim_no: 'EXP-PROFILE-LEGACY',
|
||||
employee_name: 'Legacy Alice',
|
||||
department_name: 'Finance',
|
||||
expense_type: 'travel',
|
||||
reason: 'Trip',
|
||||
location: 'Shanghai',
|
||||
amount: 1200,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T02:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
risk_flags_json: [],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.employeeId, '')
|
||||
assert.equal(request.employee_id, '')
|
||||
assert.equal(request.profileEmployeeId, 'Legacy Alice')
|
||||
})
|
||||
|
||||
test('application claims are mapped as application documents', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-1',
|
||||
@@ -46,11 +100,12 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === DIRECT_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.rawLabel, DIRECT_MANAGER_APPROVAL)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
|
||||
})
|
||||
@@ -96,6 +151,48 @@ test('application claims wait for department P8 budget monitor after leader appr
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
})
|
||||
|
||||
test('application budget wait label uses claim-level budget approver snapshot', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-budget-snapshot',
|
||||
claim_no: 'AP-20260525103145-BUDGET-SNAPSHOT',
|
||||
employee_name: 'Applicant Zhang',
|
||||
department_name: 'Engineering',
|
||||
manager_name: 'Leader Li',
|
||||
budget_approver_name: 'P8 Executive',
|
||||
budget_approver_grade: 'P8',
|
||||
budget_approver_role_code: 'executive',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Production deployment support',
|
||||
location: 'Beijing',
|
||||
amount: 12000,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T03:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: BUDGET_MANAGER_APPROVAL,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'Leader Li',
|
||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
next_approval_stage: BUDGET_MANAGER_APPROVAL,
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.budgetApproverName, 'P8 Executive')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
|
||||
})
|
||||
|
||||
test('returned application claims include leader return node and supplement status', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-returned',
|
||||
@@ -235,6 +332,57 @@ test('application claims hide budget step when leader approval also covers budge
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李预算经理通过')
|
||||
})
|
||||
|
||||
test('approved application claims hide budget step when dynamic route skipped budget review', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-skipped-budget',
|
||||
claim_no: 'APP-20260525-SKIPPED',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Project onsite support',
|
||||
location: 'Shanghai',
|
||||
amount: 500,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T03:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: APPROVAL_COMPLETED,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'approval_routing',
|
||||
event_type: 'expense_application_route_decision',
|
||||
requires_budget_review: false,
|
||||
route: 'approval_done',
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
},
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'Leader Li',
|
||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
next_approval_stage: APPROVAL_COMPLETED,
|
||||
route_decision: {
|
||||
requires_budget_review: false,
|
||||
route: 'approval_done'
|
||||
},
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
})
|
||||
|
||||
test('progress steps show approval operator time and current stay duration', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||
@@ -567,6 +715,48 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||
})
|
||||
|
||||
test('reimbursement detail resolves linked application from guided entry context', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-linked-context',
|
||||
claim_no: 'EXP-20260520-009',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
expense_type: 'travel',
|
||||
reason: '支撑国网仿生产环境部署',
|
||||
location: '北京',
|
||||
amount: 654,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T02:00:00.000Z',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'application_link',
|
||||
event_type: 'expense_reimbursement_application_linked',
|
||||
review_form_values: {
|
||||
application_claim_id: 'application-guided-1',
|
||||
application_claim_no: 'AP-202605-001',
|
||||
application_reason: '支撑国网仿生产环境部署',
|
||||
application_location: '北京',
|
||||
application_amount: '3000',
|
||||
application_amount_label: '¥3,000'
|
||||
},
|
||||
expense_scene_selection: {
|
||||
application_claim_no: 'AP-202605-001'
|
||||
}
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.relatedApplication.claimNo, 'AP-202605-001')
|
||||
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
||||
assert.equal(request.relatedApplication.location, '北京')
|
||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||
})
|
||||
|
||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime()
|
||||
|
||||
@@ -21,6 +21,8 @@ const overviewTemplate = readFileSync(
|
||||
test('risk dashboard normalizes amount, distributions, and ranking fields', () => {
|
||||
const dashboard = normalizeRiskObservationDashboard({
|
||||
total_observations: 5,
|
||||
risk_clue_count: 2,
|
||||
feedback_sample_count: 3,
|
||||
total_amount: 12800,
|
||||
department_distribution: { 风控部: 3 },
|
||||
expense_type_distribution: { travel: 2 },
|
||||
@@ -35,6 +37,8 @@ test('risk dashboard normalizes amount, distributions, and ranking fields', () =
|
||||
})
|
||||
|
||||
assert.equal(dashboard.totalAmount, 12800)
|
||||
assert.equal(dashboard.riskClueCount, 2)
|
||||
assert.equal(dashboard.feedbackSampleCount, 3)
|
||||
assert.equal(dashboard.departmentDistribution['风控部'], 3)
|
||||
assert.equal(dashboard.expenseTypeDistribution.travel, 2)
|
||||
assert.equal(dashboard.riskTypeDistribution.duplicate_invoice, 2)
|
||||
@@ -51,6 +55,9 @@ test('risk dashboard renders overview amount and multi-dimension panels', () =>
|
||||
assert.match(overviewViewModel, /label: '误报数量'/)
|
||||
assert.match(dashboardComponent, /业务维度分布/)
|
||||
assert.match(dashboardComponent, /异常排行/)
|
||||
assert.match(dashboardComponent, /待复核线索/)
|
||||
assert.match(dashboardComponent, /反馈样本/)
|
||||
assert.doesNotMatch(dashboardComponent, /候选规则/)
|
||||
assert.match(dashboardComponent, /departmentDistribution/)
|
||||
assert.match(dashboardComponent, /expenseTypeDistribution/)
|
||||
assert.match(dashboardComponent, /supplierDistribution/)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user