feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
BIN
web/UI/预算中心.jpg
Normal file
BIN
web/UI/预算中心.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
@@ -36,6 +36,8 @@
|
||||
.risk-sim-context-panel span,
|
||||
.risk-sim-result-head span,
|
||||
.risk-sim-evidence span,
|
||||
.risk-sim-recognition-debug > span,
|
||||
.risk-sim-recognized-fields > span,
|
||||
.risk-sim-file-strip > span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
@@ -115,6 +117,12 @@
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.risk-sim-meta .tone-critical {
|
||||
border-color: #fca5a5;
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.risk-sim-main {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -263,6 +271,78 @@
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article header strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-recognition-debug article header em {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.risk-sim-debug-field-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.risk-sim-debug-field-list b {
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-debug-ocr-text {
|
||||
max-height: 112px;
|
||||
overflow: auto;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.risk-sim-result-card {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
@@ -364,6 +444,53 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields ul {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields li {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 0.75fr) auto minmax(130px, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields strong,
|
||||
.risk-sim-recognized-fields b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields em {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-sim-evidence {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -730,6 +857,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.risk-sim-recognized-fields li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.risk-sim-foot {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -935,6 +935,7 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox']),
|
||||
.risk-rule-create-form select,
|
||||
.risk-rule-create-form textarea {
|
||||
width: 100%;
|
||||
@@ -945,6 +946,7 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox']),
|
||||
.risk-rule-create-form select {
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
@@ -957,6 +959,7 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox']):focus,
|
||||
.risk-rule-create-form select:focus,
|
||||
.risk-rule-create-form textarea:focus {
|
||||
outline: 0;
|
||||
@@ -964,6 +967,7 @@
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.risk-rule-create-form input:not([type='checkbox'])::placeholder,
|
||||
.risk-rule-create-form textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
@@ -1045,6 +1049,118 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.risk-level-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action b {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.low {
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.low b {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.medium {
|
||||
border-color: rgba(249, 115, 22, 0.26);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.medium b {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.high {
|
||||
border-color: rgba(220, 38, 38, 0.22);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.minor-action.risk-level-action.high b {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.risk-level-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 35;
|
||||
width: 146px;
|
||||
padding: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.risk-level-option {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.risk-level-option:hover,
|
||||
.risk-level-option.active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.risk-level-option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.risk-level-option i {
|
||||
margin-left: auto;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk-level-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.risk-level-option.low .risk-level-dot {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.risk-level-option.medium .risk-level-dot {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.risk-level-option.high .risk-level-dot {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.minor-action.danger-action {
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #dc2626;
|
||||
@@ -1174,6 +1290,8 @@
|
||||
.search-filter,
|
||||
.picker-trigger,
|
||||
.picker-filter,
|
||||
.risk-level-switch,
|
||||
.risk-level-switch .minor-action,
|
||||
.toolbar-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1303,6 +1421,62 @@
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.json-risk-score-ring {
|
||||
--score-ring: #f97316;
|
||||
--score-ring-bg: #fff7ed;
|
||||
flex: 0 0 auto;
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 1px;
|
||||
border: 2px solid var(--score-ring);
|
||||
background: var(--score-ring-bg);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.json-risk-score-ring strong {
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.json-risk-score-ring span,
|
||||
.json-risk-score-ring em {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.json-risk-score-ring em {
|
||||
color: var(--score-ring);
|
||||
}
|
||||
|
||||
.json-risk-score-ring.low {
|
||||
--score-ring: #2563eb;
|
||||
--score-ring-bg: #eff6ff;
|
||||
}
|
||||
|
||||
.json-risk-score-ring.medium {
|
||||
--score-ring: #f97316;
|
||||
--score-ring-bg: #fff7ed;
|
||||
}
|
||||
|
||||
.json-risk-score-ring.high {
|
||||
--score-ring: #dc2626;
|
||||
--score-ring-bg: #fef2f2;
|
||||
}
|
||||
|
||||
.json-risk-score-ring.critical {
|
||||
--score-ring: #991b1b;
|
||||
--score-ring-bg: #fff1f2;
|
||||
}
|
||||
|
||||
.json-risk-editor-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -1423,6 +1597,55 @@
|
||||
padding: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure {
|
||||
min-height: 360px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 14px;
|
||||
padding: 44px 24px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
background: #fffafa;
|
||||
text-align: center;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure i {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure h3 {
|
||||
margin: 0;
|
||||
color: #991b1b;
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure p {
|
||||
max-width: 520px;
|
||||
margin: 8px auto 0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.json-risk-generation-failure small {
|
||||
display: block;
|
||||
max-width: 640px;
|
||||
margin-top: 12px;
|
||||
color: #b91c1c;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.json-risk-main-stage {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1521,14 +1744,19 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.critical {
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.medium {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.low {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.json-risk-meta-badge.test-passed {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
499
web/src/assets/styles/views/budget-center-view.css
Normal file
499
web/src/assets/styles/views/budget-center-view.css
Normal file
@@ -0,0 +1,499 @@
|
||||
.budget-center-page {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.budget-local-head {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.budget-local-head h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-summary-card {
|
||||
min-height: 118px;
|
||||
padding: 22px 28px;
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
border-right: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.budget-summary-card:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.summary-icon.green {
|
||||
background: #e8f7ef;
|
||||
color: #07965f;
|
||||
}
|
||||
|
||||
.summary-icon.blue {
|
||||
background: #edf4ff;
|
||||
color: #2f7fd7;
|
||||
}
|
||||
|
||||
.summary-icon.orange {
|
||||
background: #fff4e5;
|
||||
color: #df9300;
|
||||
}
|
||||
|
||||
.budget-summary-card span:not(.summary-icon) {
|
||||
display: block;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.budget-summary-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-summary-card em {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: #8a94a6;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.budget-filter-bar {
|
||||
min-height: 62px;
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 12px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.budget-filter-bar label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.budget-filter-bar select,
|
||||
.budget-table-foot select {
|
||||
height: 34px;
|
||||
min-width: 150px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
padding: 0 34px 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.budget-primary-btn {
|
||||
margin-left: auto;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: #0aa66f;
|
||||
color: #fff;
|
||||
padding: 0 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-work-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-department-panel,
|
||||
.budget-table-panel,
|
||||
.budget-chart-panel,
|
||||
.budget-alert-panel {
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-department-panel header,
|
||||
.budget-table-panel > header,
|
||||
.budget-card-head {
|
||||
min-height: 48px;
|
||||
padding: 13px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.budget-department-panel strong,
|
||||
.budget-table-panel > header strong,
|
||||
.budget-card-head strong {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.department-search {
|
||||
position: relative;
|
||||
margin: 12px 14px 8px;
|
||||
}
|
||||
|
||||
.department-search i {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #9aa5b5;
|
||||
}
|
||||
|
||||
.department-search input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
padding: 0 12px 0 34px;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 12px 16px;
|
||||
}
|
||||
|
||||
.department-list button {
|
||||
height: 38px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #4b5563;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.department-list button.active {
|
||||
background: #e9f7f1;
|
||||
color: #07965f;
|
||||
}
|
||||
|
||||
.budget-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.budget-table-panel table {
|
||||
width: 100%;
|
||||
min-width: 1040px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.budget-table-panel th,
|
||||
.budget-table-panel td {
|
||||
padding: 13px 18px;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
border-right: 1px solid #edf1f6;
|
||||
color: #273142;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-table-panel th:last-child,
|
||||
.budget-table-panel td:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.budget-table-panel th {
|
||||
background: #fafbfd;
|
||||
color: #1f2937;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-rate {
|
||||
width: 96px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.budget-rate span {
|
||||
color: #273142;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.budget-rate div {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #e9edf3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-rate em {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.budget-rate em.ok {
|
||||
background: #13a66b;
|
||||
}
|
||||
|
||||
.budget-rate em.warn {
|
||||
background: #f2a51a;
|
||||
}
|
||||
|
||||
.budget-rate em.danger {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.budget-warning-red {
|
||||
color: #e24b4b !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-warning-yellow {
|
||||
color: #e3a008 !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.budget-row-actions button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1c7ed6;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-table-foot {
|
||||
min-height: 52px;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-table-foot button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.budget-table-foot button.active {
|
||||
border-color: #10a873;
|
||||
color: #10a873;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-table-foot span {
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.budget-bottom-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.82fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-card-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1c7ed6;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.budget-chart-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.legend-line {
|
||||
width: 18px;
|
||||
height: 0;
|
||||
border-top: 2px dashed #2f7fd7;
|
||||
}
|
||||
|
||||
.legend-line.used {
|
||||
border-top-style: solid;
|
||||
border-top-color: #13a66b;
|
||||
}
|
||||
|
||||
.budget-chart-panel {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.budget-chart-panel .budget-trend-chart {
|
||||
margin: 12px 18px 0;
|
||||
}
|
||||
|
||||
.budget-alert-list {
|
||||
display: grid;
|
||||
padding: 12px 20px 18px;
|
||||
}
|
||||
|
||||
.budget-alert-row {
|
||||
min-height: 46px;
|
||||
display: grid;
|
||||
grid-template-columns: 12px 120px minmax(0, 1fr) 92px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.budget-alert-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.budget-alert-row i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.budget-alert-row i.danger {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.budget-alert-row i.warn {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.budget-alert-row i.ok {
|
||||
background: #13a66b;
|
||||
}
|
||||
|
||||
.budget-alert-row strong {
|
||||
color: #273142;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.budget-alert-row span {
|
||||
min-width: 0;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-alert-row time {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.budget-summary-grid,
|
||||
.budget-bottom-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.budget-work-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.budget-department-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.budget-summary-grid,
|
||||
.budget-bottom-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.budget-filter-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.budget-filter-bar label,
|
||||
.budget-filter-bar select,
|
||||
.budget-primary-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.budget-alert-row {
|
||||
grid-template-columns: 12px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.budget-alert-row span,
|
||||
.budget-alert-row time {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
@@ -647,6 +647,10 @@
|
||||
box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48);
|
||||
}
|
||||
|
||||
.message-bubble-application-preview {
|
||||
max-width: min(100%, 980px);
|
||||
}
|
||||
|
||||
.message-bubble-review-risk-low {
|
||||
border-color: rgba(37, 99, 235, 0.72);
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.96));
|
||||
@@ -759,6 +763,170 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.application-preview-table {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e4f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
font-size: var(--wb-fs-bubble);
|
||||
}
|
||||
|
||||
.application-preview-footer {
|
||||
margin-top: 12px;
|
||||
color: #334155;
|
||||
font-size: var(--wb-fs-bubble);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.application-preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: 108px minmax(0, 1fr);
|
||||
min-height: 38px;
|
||||
border-top: 1px solid #e6edf5;
|
||||
}
|
||||
|
||||
.application-preview-row.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.application-preview-row.editable:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.application-preview-row.editable:hover .application-preview-label,
|
||||
.application-preview-row.editable:hover .application-preview-value {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.application-preview-row.editable.missing:hover .application-preview-value {
|
||||
background: #fff4e6;
|
||||
}
|
||||
|
||||
.application-preview-row.editable:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.application-preview-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.application-preview-row.head {
|
||||
min-height: 34px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: var(--wb-fs-caption);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-preview-row > span {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.application-preview-label {
|
||||
border-right: 1px solid #e6edf5;
|
||||
background: #fbfdff;
|
||||
color: #64748b;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.application-preview-value {
|
||||
position: relative;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.application-preview-row.missing .application-preview-value {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.application-preview-row.highlight .application-preview-label {
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.application-preview-row.highlight .application-preview-value {
|
||||
background: #f7fee7;
|
||||
color: #166534;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.application-preview-row.highlight.missing .application-preview-value {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.application-preview-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.application-preview-row:hover .application-preview-edit-btn,
|
||||
.application-preview-edit-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn:hover:not(:disabled),
|
||||
.application-preview-edit-btn:focus-visible {
|
||||
border-color: #bfdbfe;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.application-preview-edit-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.application-preview-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
padding: 0 9px;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.application-preview-select {
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(ul),
|
||||
.message-answer-markdown :deep(ol) {
|
||||
margin: 0;
|
||||
|
||||
@@ -725,12 +725,18 @@
|
||||
}
|
||||
|
||||
.hero-fact-grid {
|
||||
grid-template-columns: repeat(5, minmax(132px, 1fr));
|
||||
overflow-x: auto;
|
||||
grid-template-columns: minmax(280px, 1.4fr) repeat(3, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hero-fact {
|
||||
min-width: 132px;
|
||||
min-width: 0;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.hero-fact strong {
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
@@ -822,6 +828,18 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.application-detail-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-detail-fact {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.application-detail-fact:nth-child(2) {
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
.hero-fact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(240px, 1.25fr) repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@@ -216,13 +216,20 @@
|
||||
}
|
||||
|
||||
.hero-fact strong {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-fact:first-child strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hero-fact strong.amount {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
@@ -489,6 +496,20 @@
|
||||
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;
|
||||
@@ -571,6 +592,70 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.application-detail-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.application-detail-fact {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 28%) minmax(0, 1fr);
|
||||
min-height: 48px;
|
||||
border-top: 1px solid #edf2f7;
|
||||
border-left: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.application-detail-fact:nth-child(-n + 2) {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.application-detail-fact:nth-child(2n + 1) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.application-detail-fact span,
|
||||
.application-detail-fact strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 11px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.application-detail-fact span {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.application-detail-fact strong {
|
||||
border-left: 1px solid #edf2f7;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.application-detail-fact.highlight span {
|
||||
background: #eefcf6;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.application-detail-fact.highlight strong {
|
||||
background: #f6fef9;
|
||||
}
|
||||
|
||||
.application-detail-fact.emphasis strong {
|
||||
color: #047857;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.detail-note-editor {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -640,6 +725,54 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
|
||||
.application-leader-opinion {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 850;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head span i {
|
||||
margin-top: 1px;
|
||||
color: #334155;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.application-leader-opinion-head strong {
|
||||
color: #047857;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inline-leader-opinion {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.application-leader-opinion-display {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
|
||||
142
web/src/components/charts/BudgetTrendChart.vue
Normal file
142
web/src/components/charts/BudgetTrendChart.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="budget-trend-chart">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend)
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
budget: { type: Array, required: true },
|
||||
used: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const progress = useAnimationProgress([
|
||||
() => props.labels,
|
||||
() => props.budget,
|
||||
() => props.used
|
||||
], 1000)
|
||||
|
||||
const scaleSeries = (series) =>
|
||||
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '预算',
|
||||
data: scaleSeries(props.budget),
|
||||
borderColor: '#2f7fd7',
|
||||
backgroundColor: 'rgba(47, 127, 215, 0.08)',
|
||||
borderDash: [7, 5],
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: '#ffffff',
|
||||
pointBorderColor: '#2f7fd7',
|
||||
pointBorderWidth: 2,
|
||||
tension: 0.34,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '已发生',
|
||||
data: scaleSeries(props.used),
|
||||
borderColor: '#13a66b',
|
||||
backgroundColor: 'rgba(19, 166, 107, 0.12)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: '#ffffff',
|
||||
pointBorderColor: '#13a66b',
|
||||
pointBorderWidth: 2,
|
||||
tension: 0.34,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
animation: {
|
||||
duration: 760,
|
||||
easing: 'easeOutQuart'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
bodyColor: '#475569',
|
||||
titleColor: '#0f172a',
|
||||
padding: 12,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label(context) {
|
||||
const value = Number(context.parsed.y || 0)
|
||||
return `${context.dataset.label}: ${value.toLocaleString('zh-CN')} 元`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 12 }
|
||||
},
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 12000000,
|
||||
grid: {
|
||||
color: '#edf2f7',
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 12 },
|
||||
stepSize: 3000000,
|
||||
callback(value) {
|
||||
if (value === 0) return '0'
|
||||
return `${Number(value) / 10000}万`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-trend-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span class="nav-label">{{ item.displayLabel }}</span>
|
||||
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span>
|
||||
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -83,7 +84,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: { type: Array, required: true },
|
||||
@@ -113,19 +114,17 @@ const props = defineProps({
|
||||
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
||||
|
||||
const {
|
||||
badgeLabel: approvalBadgeLabel,
|
||||
refreshApprovalInbox,
|
||||
startApprovalInboxPolling,
|
||||
stopApprovalInboxPolling
|
||||
} = useApprovalInbox()
|
||||
hasUnread: documentInboxHasUnread,
|
||||
refreshDocumentInbox,
|
||||
startDocumentInboxPolling,
|
||||
stopDocumentInboxPolling
|
||||
} = useDocumentCenterInbox()
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '财务总览' },
|
||||
workbench: { label: '个人工作台' },
|
||||
documents: { label: '单据中心' },
|
||||
requests: { label: '报销中心' },
|
||||
approval: { label: '审批中心' },
|
||||
archive: { label: '归档中心' },
|
||||
budget: { label: '预算中心' },
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '任务规则中心' },
|
||||
logs: { label: '日志管理' },
|
||||
@@ -137,13 +136,14 @@ const decoratedNavItems = computed(() =>
|
||||
props.navItems.map((item) => ({
|
||||
...item,
|
||||
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
||||
badge: item.id === 'approval' ? approvalBadgeLabel.value : sidebarMeta[item.id]?.badge
|
||||
hasNewMessage: item.id === 'documents' ? documentInboxHasUnread.value : false,
|
||||
badge: sidebarMeta[item.id]?.badge
|
||||
}))
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void refreshApprovalInbox()
|
||||
startApprovalInboxPolling()
|
||||
void refreshDocumentInbox()
|
||||
startDocumentInboxPolling()
|
||||
})
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopApprovalInboxPolling()
|
||||
stopDocumentInboxPolling()
|
||||
closeCollapsedUserMenuNow()
|
||||
})
|
||||
</script>
|
||||
@@ -463,6 +463,16 @@ onBeforeUnmount(() => {
|
||||
opacity var(--rail-fade-duration) var(--rail-motion-ease);
|
||||
}
|
||||
|
||||
.nav-unread-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26);
|
||||
}
|
||||
|
||||
.rail-user {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -668,6 +678,14 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-collapsed .nav-unread-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.rail-collapsed {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -47,26 +47,11 @@
|
||||
<div
|
||||
v-else
|
||||
class="risk-rule-flow-svg-viewport"
|
||||
@mousedown="onDragStart"
|
||||
@touchstart="onTouchStart"
|
||||
@dblclick="resetZoom"
|
||||
>
|
||||
<div
|
||||
class="risk-rule-flow-svg-canvas"
|
||||
:style="transformStyle"
|
||||
v-html="displaySvg"
|
||||
></div>
|
||||
<div class="diagram-zoom-controls" @mousedown.stop @touchstart.stop>
|
||||
<button class="zoom-btn" @click="zoomIn" title="放大">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
</button>
|
||||
<button class="zoom-btn" @click="zoomOut" title="缩小">
|
||||
<i class="mdi mdi-minus"></i>
|
||||
</button>
|
||||
<button class="zoom-btn" @click="resetZoom" title="重置">
|
||||
<i class="mdi mdi-arrow-expand-all"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,87 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
|
||||
const scale = ref(1)
|
||||
const translateX = ref(0)
|
||||
const translateY = ref(0)
|
||||
const isDragging = ref(false)
|
||||
const dragStart = { x: 0, y: 0 }
|
||||
|
||||
const transformStyle = computed(() => ({
|
||||
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value})`,
|
||||
transformOrigin: 'center center',
|
||||
transition: isDragging.value ? 'none' : 'transform 0.15s ease-out'
|
||||
}))
|
||||
|
||||
function onDragStart(e) {
|
||||
if (e.button !== 0) return
|
||||
isDragging.value = true
|
||||
dragStart.x = e.clientX - translateX.value
|
||||
dragStart.y = e.clientY - translateY.value
|
||||
|
||||
window.addEventListener('mousemove', onDragging)
|
||||
window.addEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
function onDragging(e) {
|
||||
if (!isDragging.value) return
|
||||
translateX.value = e.clientX - dragStart.x
|
||||
translateY.value = e.clientY - dragStart.y
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging.value = false
|
||||
window.removeEventListener('mousemove', onDragging)
|
||||
window.removeEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (e.touches.length !== 1) return
|
||||
isDragging.value = true
|
||||
const touch = e.touches[0]
|
||||
dragStart.x = touch.clientX - translateX.value
|
||||
dragStart.y = touch.clientY - translateY.value
|
||||
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
window.addEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!isDragging.value || e.touches.length !== 1) return
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
translateX.value = touch.clientX - dragStart.x
|
||||
translateY.value = touch.clientY - dragStart.y
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
isDragging.value = false
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
window.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(scale.value + 0.15, 3)
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(scale.value - 0.15, 0.4)
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
scale.value = 1
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onDragging)
|
||||
window.removeEventListener('mouseup', onDragEnd)
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
window.removeEventListener('touchend', onTouchEnd)
|
||||
})
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
svg: { type: String, default: '' },
|
||||
@@ -188,6 +93,12 @@ const PALETTES = {
|
||||
accentDark: '#b91c1c',
|
||||
border: '#fecaca',
|
||||
surface: '#fef2f2'
|
||||
},
|
||||
critical: {
|
||||
accent: '#991b1b',
|
||||
accentDark: '#7f1d1d',
|
||||
border: '#fca5a5',
|
||||
surface: '#fff1f2'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +109,8 @@ const DRAWIO_PALETTES = {
|
||||
green: { fill: '#ffffff', stroke: '#e2e8f0' },
|
||||
low: { fill: '#eff6ff', stroke: '#bfdbfe' },
|
||||
medium: { fill: '#fff7ed', stroke: '#fed7aa' },
|
||||
high: { fill: '#fef2f2', stroke: '#fecaca' }
|
||||
high: { fill: '#fef2f2', stroke: '#fecaca' },
|
||||
critical: { fill: '#fff1f2', stroke: '#fca5a5' }
|
||||
}
|
||||
|
||||
function normalizeText(value, fallback = '') {
|
||||
@@ -222,7 +134,11 @@ function isSafeSvg(value) {
|
||||
}
|
||||
|
||||
function isCurrentDisplaySvg(value) {
|
||||
return isSafeSvg(value) && value.includes('data-risk-flow-style="review-node-only"')
|
||||
return (
|
||||
isSafeSvg(value) &&
|
||||
value.includes('data-risk-flow-style="review-node-only"') &&
|
||||
value.includes('data-risk-flow-detail="logic-v2"')
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePalette(severity) {
|
||||
@@ -262,10 +178,15 @@ function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function truncateText(value, length) {
|
||||
const text = normalizeText(value)
|
||||
return text.length <= length ? text : `${text.slice(0, Math.max(0, length - 1))}…`
|
||||
}
|
||||
|
||||
function node(title, body, x, y, width, height, type = 'blue') {
|
||||
const palette = DRAWIO_PALETTES[type] || DRAWIO_PALETTES.blue
|
||||
return `<g class="drawio-node">
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="3" ry="3" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
|
||||
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
|
||||
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', '#475569', 11)}
|
||||
</g>`
|
||||
@@ -277,7 +198,7 @@ function diamond(title, body, x, y, width, height) {
|
||||
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
|
||||
const palette = DRAWIO_PALETTES.yellow
|
||||
return `<g class="drawio-node">
|
||||
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
|
||||
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
|
||||
<text x="${cx}" y="${cy - 8}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
|
||||
${textLines(wrapText(body, 8, 2), cx, cy + 12, 'middle', '#475569', 10.2)}
|
||||
</g>`
|
||||
@@ -291,6 +212,24 @@ function note(body) {
|
||||
</g>`
|
||||
}
|
||||
|
||||
function panel(title, rows, x, y, width, height) {
|
||||
const visibleRows = (Array.isArray(rows) ? rows : [])
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.map((row) => truncateText(row, 34))
|
||||
const renderedRows = visibleRows.length ? visibleRows : ['读取规则字段并归一化为判断事实']
|
||||
return `<g class="drawio-node panel-node">
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.2"/>
|
||||
<text x="${x + 16}" y="${y + 26}" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="700">${escapeSvg(title)}</text>
|
||||
${renderedRows
|
||||
.map(
|
||||
(row, index) =>
|
||||
`<text x="${x + 16}" y="${y + 48 + index * 18}" fill="#334155" font-family="${FONT}" font-size="11" font-weight="400">${escapeSvg(row)}</text>`
|
||||
)
|
||||
.join('')}
|
||||
</g>`
|
||||
}
|
||||
|
||||
const palette = computed(() => resolvePalette(props.severity))
|
||||
|
||||
const accentStyle = computed(() => ({
|
||||
@@ -323,6 +262,11 @@ const flowModel = computed(() => {
|
||||
evidence: normalizeText(props.flow?.evidence, '读取规则字段'),
|
||||
decision: normalizeText(props.flow?.decision, '判断是否命中风险'),
|
||||
basis: normalizeText(props.flow?.basis || props.flow?.decision, '根据规则字段判断是否命中风险'),
|
||||
facts: Array.isArray(props.flow?.facts) ? props.flow.facts.map(normalizeText).filter(Boolean) : [],
|
||||
conditions: Array.isArray(props.flow?.conditions)
|
||||
? props.flow.conditions.map(normalizeText).filter(Boolean)
|
||||
: [],
|
||||
hitLogic: normalizeText(props.flow?.hitLogic || props.flow?.formula),
|
||||
pass: normalizeText(props.flow?.pass, '未命中风险,继续流转'),
|
||||
fail: normalizeText(props.flow?.fail, `命中${severityLabel},进入人工复核`)
|
||||
}
|
||||
@@ -336,11 +280,12 @@ const flowSteps = computed(() => [
|
||||
{
|
||||
title: '字段取数',
|
||||
text: `读取规则所需字段,并将字段证据送入判断节点。字段:${fieldSummary.value}`,
|
||||
fields: fieldDisplays.value
|
||||
fields: flowModel.value.facts.length ? flowModel.value.facts : fieldDisplays.value
|
||||
},
|
||||
{
|
||||
title: '判断依据',
|
||||
text: flowModel.value.basis || flowModel.value.decision
|
||||
text: flowModel.value.basis || flowModel.value.decision,
|
||||
fields: flowModel.value.conditions
|
||||
}
|
||||
])
|
||||
|
||||
@@ -367,46 +312,39 @@ const displaySvg = computed(() => {
|
||||
|
||||
const flow = flowModel.value
|
||||
const severity = props.severity
|
||||
const facts = flow.facts.length ? flow.facts : fieldDisplays.value.slice(0, 4)
|
||||
const conditions = flow.conditions.length ? flow.conditions : [flow.basis || flow.decision]
|
||||
const hitLogic = flow.hitLogic || flow.basis || flow.decision
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="860" height="360" viewBox="0 0 860 360" data-risk-flow-style="review-node-only" data-risk-flow-detail="logic-v2" role="img" aria-label="风险规则流程说明">
|
||||
<defs>
|
||||
<pattern id="grid" width="16" height="16" patternUnits="userSpaceOnUse">
|
||||
<path d="M 16 0 L 0 0 0 16" fill="none" stroke="#e8ecef" stroke-width="0.75"/>
|
||||
</pattern>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#666666"/>
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#cbd5e1"/>
|
||||
</marker>
|
||||
<marker id="arrow-risk" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="${palette.value.accent}"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.08" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="760" height="280" fill="#ffffff"/>
|
||||
<rect width="760" height="280" fill="url(#grid)"/>
|
||||
<rect x="0.5" y="0.5" width="759.5" height="279.5" rx="6" fill="none" stroke="#cbd5e1" stroke-width="1"/>
|
||||
<rect width="860" height="360" fill="#ffffff"/>
|
||||
<rect x="18" y="18" width="824" height="324" rx="8" fill="none" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
|
||||
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700" letter-spacing="0.05em">RULE FLOW CANVAS</text>
|
||||
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700">RULE FLOW</text>
|
||||
|
||||
${node('业务输入', flow.start, 48, 118, 124, 60, 'neutral')}
|
||||
${node('字段取数', '读取字段证据', 214, 118, 132, 60, 'blue')}
|
||||
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
|
||||
${node('继续流转', flow.pass, 562, 74, 126, 60, 'green')}
|
||||
${node('进入复核', flow.fail, 562, 190, 126, 62, severity)}
|
||||
${note(flow.basis)}
|
||||
${node('业务输入', flow.start, 38, 142, 120, 62, 'neutral')}
|
||||
${panel('字段事实', facts, 196, 64, 240, 128)}
|
||||
${panel('判断条件', conditions, 196, 216, 382, 104)}
|
||||
${diamond('命中逻辑', hitLogic, 494, 80, 122, 122)}
|
||||
${node('继续流转', flow.pass, 688, 76, 122, 60, 'neutral')}
|
||||
${node('进入复核', flow.fail, 688, 226, 122, 68, severity)}
|
||||
|
||||
<line x1="172" y1="148" x2="214" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<line x1="346" y1="148" x2="392" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<g>
|
||||
<rect x="521" y="108" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
|
||||
<text x="532" y="120" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">否</text>
|
||||
</g>
|
||||
|
||||
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<g>
|
||||
<rect x="521" y="187" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
|
||||
<text x="532" y="199" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">是</text>
|
||||
</g>
|
||||
<path d="M 158 173 H 176 V 128 H 196" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<line x1="316" y1="192" x2="316" y2="216" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M 436 128 H 466 V 141 H 494" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<line x1="555" y1="216" x2="555" y2="202" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M 616 125 H 648 V 106 H 688" fill="none" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
||||
<text x="651" y="119" text-anchor="middle" fill="#64748b" font-family="${FONT}" font-size="10.5" font-weight="500">否</text>
|
||||
<path d="M 616 166 H 648 V 260 H 688" fill="none" stroke="${palette.value.accent}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-risk)"/>
|
||||
<text x="651" y="214" text-anchor="middle" fill="${palette.value.accentDark}" font-family="${FONT}" font-size="10.5" font-weight="700">是</text>
|
||||
</svg>`
|
||||
})
|
||||
</script>
|
||||
@@ -566,7 +504,7 @@ const displaySvg = computed(() => {
|
||||
.risk-rule-flow-svg-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
@@ -575,16 +513,11 @@ const displaySvg = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.risk-rule-flow-svg-viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.risk-rule-flow-svg-canvas {
|
||||
width: 760px;
|
||||
height: 280px;
|
||||
width: 860px;
|
||||
height: 360px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -633,7 +566,7 @@ const displaySvg = computed(() => {
|
||||
}
|
||||
|
||||
.risk-rule-flow-image {
|
||||
width: min(760px, 100%);
|
||||
width: min(860px, 100%);
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
@@ -49,6 +49,31 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message.recognitionDocuments?.length" class="risk-sim-recognition-debug">
|
||||
<span>单据识别明细</span>
|
||||
<article
|
||||
v-for="document in message.recognitionDocuments"
|
||||
:key="`${message.id}-${document.filename}`"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ document.filename || '临时单据' }}</strong>
|
||||
<em>{{ formatDocumentMeta(document) }}</em>
|
||||
</header>
|
||||
<p v-if="document.summary">摘要:{{ document.summary }}</p>
|
||||
<div v-if="document.document_fields?.length" class="risk-sim-debug-field-list">
|
||||
<b
|
||||
v-for="field in document.document_fields"
|
||||
:key="`${document.filename}-${field.key}-${field.value}`"
|
||||
>
|
||||
{{ field.label }}[{{ field.key }}]:{{ field.value }}
|
||||
</b>
|
||||
</div>
|
||||
<p v-if="document.text" class="risk-sim-debug-ocr-text">
|
||||
OCR原文:{{ trimDebugText(document.text, 800) }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="message.result" class="risk-sim-result-card" :class="message.result.severity">
|
||||
<div class="risk-sim-result-head">
|
||||
<div>
|
||||
@@ -75,6 +100,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="buildRecognizedFieldRows(message.result).length"
|
||||
class="risk-sim-recognized-fields"
|
||||
>
|
||||
<span>规则实际取用字段</span>
|
||||
<ul>
|
||||
<li v-for="field in buildRecognizedFieldRows(message.result)" :key="field.key">
|
||||
<strong>{{ field.label }}</strong>
|
||||
<em>{{ field.source }}</em>
|
||||
<b>{{ field.value }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="buildEvidenceItems(message.result).length" class="risk-sim-evidence">
|
||||
<span>判断依据</span>
|
||||
<ul>
|
||||
@@ -262,6 +301,16 @@ import {
|
||||
formatTestError,
|
||||
formatTime
|
||||
} from './riskRuleTestDialogUtils.js'
|
||||
import {
|
||||
buildDocumentBrief,
|
||||
buildEvidenceItems as buildEvidenceItemsModel,
|
||||
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
|
||||
buildResultFields as buildResultFieldsModel,
|
||||
formatDocumentMeta,
|
||||
formatFieldLabel,
|
||||
resolveFileStatusLabel,
|
||||
trimDebugText
|
||||
} from './riskRuleTestDialogDisplay.js'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -568,8 +617,9 @@ async function recognizeTemporaryFiles(files, activeSessionId) {
|
||||
messages.value.push(buildMessage(
|
||||
'assistant',
|
||||
recognizedCount
|
||||
? `已完成 ${recognizedCount} 份临时单据识别。请核对右侧识别字段,字段不足时可以直接在输入框补充。`
|
||||
: '上传文件没有提取到足够字段,暂不能直接执行规则。请在输入框补充票据城市、金额、发票号等关键信息。'
|
||||
? `已完成 ${recognizedCount} 份临时单据识别。下面会展示 OCR 结构化字段和原文片段,请先核对这些信息;字段不足时可以直接在输入框补充。`
|
||||
: '上传文件没有提取到足够字段。下面仍会展示 OCR 返回内容,方便判断是票据质量问题还是字段映射问题。请在输入框补充城市、金额、发票号等关键信息。',
|
||||
{ recognitionDocuments: documents }
|
||||
))
|
||||
} catch (error) {
|
||||
if (!isActiveSession(activeSessionId)) return
|
||||
@@ -601,52 +651,15 @@ function buildMessage(role, text, extra = {}) {
|
||||
}
|
||||
|
||||
function buildResultFields(result) {
|
||||
const values = result?.field_values && typeof result.field_values === 'object'
|
||||
? result.field_values
|
||||
: {}
|
||||
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
|
||||
key,
|
||||
label: formatFieldLabel(fields.value.find((field) => field.key === key) || { key }),
|
||||
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
|
||||
}))
|
||||
return buildResultFieldsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function buildRecognizedFieldRows(result) {
|
||||
return buildRecognizedFieldRowsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function buildEvidenceItems(result) {
|
||||
const evidence = result?.evidence && typeof result.evidence === 'object'
|
||||
? result.evidence
|
||||
: {}
|
||||
const items = []
|
||||
if (Array.isArray(evidence.failed_conditions)) {
|
||||
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
|
||||
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
|
||||
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
|
||||
items.push(`${formatFieldName(condition.left)}:${left};${formatFieldName(condition.right)}:${right}`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.missing_fields)) {
|
||||
evidence.missing_fields.slice(0, 5).forEach((field) => {
|
||||
items.push(`${formatFieldName(field)} 缺失`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.keyword_hits)) {
|
||||
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
|
||||
}
|
||||
if (evidence.condition_summary) {
|
||||
items.push(String(evidence.condition_summary))
|
||||
}
|
||||
return [...new Set(items)].slice(0, 5)
|
||||
}
|
||||
|
||||
function formatFieldLabel(field) {
|
||||
const key = String(field?.key || '').trim()
|
||||
const label = String(field?.display || field?.label || '').trim()
|
||||
if (!key) return label || '-'
|
||||
if (!label || label === key) return key
|
||||
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
|
||||
}
|
||||
|
||||
function formatFieldName(key) {
|
||||
return formatFieldLabel(fields.value.find((field) => field.key === key) || { key })
|
||||
return buildEvidenceItemsModel(result, fields.value)
|
||||
}
|
||||
|
||||
function toAttachmentPayload(file) {
|
||||
@@ -713,23 +726,6 @@ function documentHasMeaningfulText(document) {
|
||||
)
|
||||
}
|
||||
|
||||
function buildDocumentBrief(document) {
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
if (fields.length) {
|
||||
return fields.slice(0, 4).map((field) => `${field.label}:${field.value}`).join(';')
|
||||
}
|
||||
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
|
||||
}
|
||||
|
||||
function resolveFileStatusLabel(file) {
|
||||
return file.statusText || {
|
||||
pending: '待发送',
|
||||
recognizing: '识别中',
|
||||
recognized: '已识别',
|
||||
failed: '识别失败'
|
||||
}[file.status] || '待识别'
|
||||
}
|
||||
|
||||
function buildRecognitionStepDescription() {
|
||||
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
|
||||
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
|
||||
|
||||
110
web/src/components/shared/riskRuleTestDialogDisplay.js
Normal file
110
web/src/components/shared/riskRuleTestDialogDisplay.js
Normal file
@@ -0,0 +1,110 @@
|
||||
export function formatFieldLabel(field) {
|
||||
const key = String(field?.key || '').trim()
|
||||
const label = String(field?.display || field?.label || '').trim()
|
||||
if (!key) return label || '-'
|
||||
if (!label || label === key) return key
|
||||
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
|
||||
}
|
||||
|
||||
export function buildResultFields(result, fields = []) {
|
||||
const values = result?.field_values && typeof result.field_values === 'object'
|
||||
? result.field_values
|
||||
: {}
|
||||
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
|
||||
key,
|
||||
label: formatFieldLabel(fields.find((field) => field.key === key) || { key }),
|
||||
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildRecognizedFieldRows(result, fields = []) {
|
||||
const rows = Array.isArray(result?.recognized_fields) ? result.recognized_fields : []
|
||||
return rows.slice(0, 12).map((field, index) => ({
|
||||
key: String(field?.key || `field-${index}`),
|
||||
label: formatFieldLabel(
|
||||
fields.find((item) => item.key === field?.key) || {
|
||||
key: field?.key,
|
||||
label: field?.label
|
||||
}
|
||||
),
|
||||
source: formatRecognitionSource(field?.source),
|
||||
value: formatDebugValue(field?.value)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildEvidenceItems(result, fields = []) {
|
||||
const evidence = result?.evidence && typeof result.evidence === 'object'
|
||||
? result.evidence
|
||||
: {}
|
||||
const items = []
|
||||
if (Array.isArray(evidence.failed_conditions)) {
|
||||
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
|
||||
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
|
||||
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
|
||||
items.push(`${formatFieldName(condition.left, fields)}:${left};${formatFieldName(condition.right, fields)}:${right}`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.missing_fields)) {
|
||||
evidence.missing_fields.slice(0, 5).forEach((field) => {
|
||||
items.push(`${formatFieldName(field, fields)} 缺失`)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(evidence.keyword_hits)) {
|
||||
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
|
||||
}
|
||||
if (evidence.condition_summary) {
|
||||
items.push(String(evidence.condition_summary))
|
||||
}
|
||||
return [...new Set(items)].slice(0, 5)
|
||||
}
|
||||
|
||||
export function buildDocumentBrief(document) {
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
if (fields.length) {
|
||||
return fields.slice(0, 6).map((field) => `${field.label}:${field.value}`).join(';')
|
||||
}
|
||||
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
|
||||
}
|
||||
|
||||
export function formatDocumentMeta(document) {
|
||||
const labels = [
|
||||
document?.document_type_label || '',
|
||||
document?.scene_label || '',
|
||||
document?.avg_score ? `置信度 ${Math.round(Number(document.avg_score) * 100)}%` : ''
|
||||
].filter(Boolean)
|
||||
return labels.join(' · ') || '未分类'
|
||||
}
|
||||
|
||||
export function resolveFileStatusLabel(file) {
|
||||
return file.statusText || {
|
||||
pending: '待发送',
|
||||
recognizing: '识别中',
|
||||
recognized: '已识别',
|
||||
failed: '识别失败'
|
||||
}[file.status] || '待识别'
|
||||
}
|
||||
|
||||
export function trimDebugText(text, maxLength = 800) {
|
||||
const value = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!value) return ''
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
|
||||
}
|
||||
|
||||
function formatRecognitionSource(source) {
|
||||
return {
|
||||
manual: '手动输入',
|
||||
ocr: 'OCR结构字段',
|
||||
inferred: '文本推断',
|
||||
model_refined: '模型过滤'
|
||||
}[String(source || '').trim()] || '未标注来源'
|
||||
}
|
||||
|
||||
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)
|
||||
return String(value ?? '-')
|
||||
}
|
||||
|
||||
function formatFieldName(key, fields) {
|
||||
return formatFieldLabel(fields.find((field) => field.key === key) || { key })
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useApprovalInbox } from './useApprovalInbox.js'
|
||||
import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.js'
|
||||
@@ -23,14 +22,15 @@ export function useAppShell() {
|
||||
const smartEntryOpen = ref(false)
|
||||
const smartEntryContext = ref({
|
||||
prompt: '',
|
||||
source: 'requests',
|
||||
source: 'documents',
|
||||
request: null,
|
||||
files: [],
|
||||
conversation: null,
|
||||
scope: null
|
||||
})
|
||||
const smartEntrySessionId = ref(0)
|
||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||
const smartEntrySessionId = ref(0)
|
||||
const smartEntryRevealToken = ref(0)
|
||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||
const selectedRequestSnapshot = ref(null)
|
||||
|
||||
const { activeView, currentView, setView } = useNavigation()
|
||||
@@ -49,7 +49,6 @@ export function useAppShell() {
|
||||
} = useRequests()
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const { refreshApprovalInbox } = useApprovalInbox()
|
||||
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
|
||||
@@ -83,21 +82,14 @@ export function useAppShell() {
|
||||
return null
|
||||
})
|
||||
|
||||
const detailMode = computed(() => ['app-request-detail', 'app-document-detail'].includes(route.name))
|
||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||
|
||||
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
|
||||
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
|
||||
const workbenchActive = computed(() => activeView.value === 'workbench')
|
||||
|
||||
watch(requestsListActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
}
|
||||
})
|
||||
|
||||
watch(documentsListActive, (isActive, wasActive) => {
|
||||
const detailMode = computed(() => route.name === 'app-document-detail')
|
||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||
|
||||
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
|
||||
const workbenchActive = computed(() => activeView.value === 'workbench')
|
||||
|
||||
watch(documentsListActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
}
|
||||
@@ -178,6 +170,10 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
function openFinancialAssistantCreate(source) {
|
||||
if (smartEntryOpen.value) {
|
||||
smartEntryRevealToken.value += 1
|
||||
return
|
||||
}
|
||||
smartEntryOpen.value = true
|
||||
smartEntryContext.value = {
|
||||
prompt: '',
|
||||
@@ -237,6 +233,7 @@ export function useAppShell() {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| normalizedClaimNo.startsWith('AP-')
|
||||
|| normalizedClaimNo.startsWith('APP-')
|
||||
)
|
||||
}
|
||||
@@ -266,8 +263,12 @@ export function useAppShell() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSmartEntry(payload = {}) {
|
||||
const conversation = await resolveSmartEntryConversation(payload)
|
||||
async function openSmartEntry(payload = {}) {
|
||||
if (smartEntryOpen.value) {
|
||||
smartEntryRevealToken.value += 1
|
||||
return
|
||||
}
|
||||
const conversation = await resolveSmartEntryConversation(payload)
|
||||
const scope = resolveSmartEntryClaimScope(payload)
|
||||
smartEntryOpen.value = true
|
||||
|
||||
@@ -294,13 +295,12 @@ export function useAppShell() {
|
||||
await reloadRequests()
|
||||
if (status === 'submitted') {
|
||||
smartEntryOpen.value = false
|
||||
void refreshApprovalInbox()
|
||||
toast(
|
||||
isApplicationDocument
|
||||
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||||
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
||||
)
|
||||
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||
router.push({ name: 'app-documents' })
|
||||
return
|
||||
}
|
||||
toast(
|
||||
@@ -310,23 +310,21 @@ export function useAppShell() {
|
||||
)
|
||||
}
|
||||
|
||||
function openRequestDetail(request) {
|
||||
selectedRequestSnapshot.value = request || null
|
||||
const routeName = activeView.value === 'documents' ? 'app-document-detail' : 'app-request-detail'
|
||||
router.push({
|
||||
name: routeName,
|
||||
params: { requestId: request.claimId || request.id }
|
||||
})
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
await reloadRequests()
|
||||
void refreshApprovalInbox()
|
||||
}
|
||||
function openRequestDetail(request) {
|
||||
selectedRequestSnapshot.value = request || null
|
||||
router.push({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: request.claimId || request.id }
|
||||
})
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
router.push({ name: 'app-documents' })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
await reloadRequests()
|
||||
}
|
||||
|
||||
async function handleRequestDeleted(payload = {}) {
|
||||
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
|
||||
@@ -335,11 +333,10 @@ export function useAppShell() {
|
||||
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
|
||||
}
|
||||
|
||||
await reloadRequests()
|
||||
void refreshApprovalInbox()
|
||||
selectedRequestSnapshot.value = null
|
||||
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||
}
|
||||
await reloadRequests()
|
||||
selectedRequestSnapshot.value = null
|
||||
router.push({ name: 'app-documents' })
|
||||
}
|
||||
|
||||
return {
|
||||
activeRange,
|
||||
@@ -374,9 +371,10 @@ export function useAppShell() {
|
||||
selectedRequest,
|
||||
setView,
|
||||
smartEntryContext,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntryRevealToken,
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
toast,
|
||||
topBarView
|
||||
|
||||
176
web/src/composables/useDocumentCenterInbox.js
Normal file
176
web/src/composables/useDocumentCenterInbox.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
|
||||
countNewDocuments,
|
||||
readViewedDocumentKeys,
|
||||
resolveDocumentNewKey
|
||||
} from '../utils/documentCenterNewState.js'
|
||||
import { mapExpenseClaimToRequest } from './useRequests.js'
|
||||
|
||||
const SOURCE_PRIORITY = {
|
||||
owned: 1,
|
||||
approval: 2,
|
||||
archive: 3
|
||||
}
|
||||
|
||||
const documentRows = ref([])
|
||||
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||
const loading = ref(false)
|
||||
let refreshTimer = null
|
||||
let viewedKeysListenerAttached = false
|
||||
|
||||
function normalizeClaimText(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildDocumentInboxRow(claim, source) {
|
||||
const request = mapExpenseClaimToRequest(claim)
|
||||
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
|
||||
const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
|
||||
const documentKey = normalizeClaimText(claimId, documentNo)
|
||||
|
||||
return documentKey
|
||||
? {
|
||||
source,
|
||||
claimId: claimId || documentKey,
|
||||
documentNo,
|
||||
documentKey: `${source}:${documentKey}`
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
function sourcePriority(row) {
|
||||
return SOURCE_PRIORITY[row?.source] || 0
|
||||
}
|
||||
|
||||
function mergeNonArchivedRows(rows) {
|
||||
const rowMap = new Map()
|
||||
|
||||
rows.filter(Boolean).forEach((row) => {
|
||||
const key = normalizeClaimText(row.claimId, row.documentNo, row.documentKey)
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = rowMap.get(key)
|
||||
if (!current || sourcePriority(row) >= sourcePriority(current)) {
|
||||
rowMap.set(key, row)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(rowMap.values())
|
||||
}
|
||||
|
||||
function uniqueRowsByNewKey(rows) {
|
||||
const seenKeys = new Set()
|
||||
|
||||
return rows.filter((row) => {
|
||||
const key = resolveDocumentNewKey(row)
|
||||
if (!key || seenKeys.has(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seenKeys.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function mapClaimsToRows(claims, source) {
|
||||
return Array.isArray(claims)
|
||||
? claims.map((claim) => buildDocumentInboxRow(claim, source)).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
export function buildDocumentInboxRows({ ownedClaims = [], approvalClaims = [], archivedClaims = [] } = {}) {
|
||||
const ownedRows = mapClaimsToRows(ownedClaims, 'owned')
|
||||
const approvalRows = mapClaimsToRows(approvalClaims, 'approval')
|
||||
const archiveRows = mapClaimsToRows(archivedClaims, 'archive')
|
||||
|
||||
return uniqueRowsByNewKey([
|
||||
...mergeNonArchivedRows([...ownedRows, ...approvalRows]),
|
||||
...archiveRows
|
||||
])
|
||||
}
|
||||
|
||||
function refreshViewedDocumentKeys() {
|
||||
viewedDocumentKeys.value = readViewedDocumentKeys()
|
||||
}
|
||||
|
||||
function attachViewedKeysListener() {
|
||||
if (typeof window === 'undefined' || viewedKeysListenerAttached) {
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys)
|
||||
viewedKeysListenerAttached = true
|
||||
}
|
||||
|
||||
async function readClaimList(fetcher) {
|
||||
const result = await fetcher()
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
export function useDocumentCenterInbox() {
|
||||
attachViewedKeysListener()
|
||||
|
||||
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
||||
const hasUnread = computed(() => unreadCount.value > 0)
|
||||
|
||||
async function refreshDocumentInbox() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([
|
||||
readClaimList(fetchExpenseClaims),
|
||||
readClaimList(fetchApprovalExpenseClaims),
|
||||
readClaimList(fetchArchivedExpenseClaims)
|
||||
])
|
||||
|
||||
documentRows.value = buildDocumentInboxRows({
|
||||
ownedClaims: ownedResult.status === 'fulfilled' ? ownedResult.value : [],
|
||||
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
|
||||
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
|
||||
})
|
||||
refreshViewedDocumentKeys()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startDocumentInboxPolling(intervalMs = 45000) {
|
||||
stopDocumentInboxPolling()
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
refreshTimer = window.setInterval(() => {
|
||||
void refreshDocumentInbox()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stopDocumentInboxPolling() {
|
||||
if (refreshTimer && typeof window !== 'undefined') {
|
||||
window.clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasUnread,
|
||||
loading,
|
||||
refreshDocumentInbox,
|
||||
startDocumentInboxPolling,
|
||||
stopDocumentInboxPolling,
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { icons } from '../data/icons.js'
|
||||
|
||||
export const appViews = ['overview', 'workbench', 'documents', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
|
||||
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings']
|
||||
|
||||
export const navItems = [
|
||||
{
|
||||
@@ -31,28 +31,12 @@ export const navItems = [
|
||||
desc: '统一查看申请、报销、审批与归档。'
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
label: '报销中心',
|
||||
navHint: '查看和管理报销单据',
|
||||
icon: icons.list,
|
||||
title: '报销中心',
|
||||
desc: '集中查看草稿、审批进度、票据状态与风险提示。'
|
||||
},
|
||||
{
|
||||
id: 'approval',
|
||||
label: '审批中心',
|
||||
navHint: '处理审批任务',
|
||||
icon: icons.approval,
|
||||
title: '审批中心',
|
||||
desc: '按优先级处理待审批事项,控制时效与风险。'
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
label: '归档中心',
|
||||
navHint: '查阅公司已归档财务数据',
|
||||
icon: icons.archive,
|
||||
title: '归档中心',
|
||||
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
|
||||
id: 'budget',
|
||||
label: '预算中心',
|
||||
navHint: '管理预算额度、预算占用与超预算预警',
|
||||
icon: icons.budget,
|
||||
title: '预算中心',
|
||||
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
|
||||
},
|
||||
{
|
||||
id: 'policies',
|
||||
@@ -100,9 +84,7 @@ const viewRouteNames = {
|
||||
overview: 'app-overview',
|
||||
workbench: 'app-workbench',
|
||||
documents: 'app-documents',
|
||||
requests: 'app-requests',
|
||||
approval: 'app-approval',
|
||||
archive: 'app-archive',
|
||||
budget: 'app-budget',
|
||||
policies: 'app-policies',
|
||||
audit: 'app-audit',
|
||||
logs: 'app-logs',
|
||||
@@ -114,7 +96,7 @@ const routeNameViews = Object.fromEntries(
|
||||
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
|
||||
)
|
||||
|
||||
routeNameViews['app-request-detail'] = 'requests'
|
||||
routeNameViews['app-request-detail'] = 'documents'
|
||||
routeNameViews['app-document-detail'] = 'documents'
|
||||
routeNameViews['app-log-detail'] = 'logs'
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ function resolveDocumentTypeMeta(claim, typeCode) {
|
||||
const isApplication =
|
||||
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||
|| explicitType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| normalizedType === 'application'
|
||||
|| normalizedType.endsWith('_application')
|
||||
|
||||
@@ -5,6 +5,7 @@ export const icons = {
|
||||
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
|
||||
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
|
||||
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
|
||||
budget: iconPath('<path d="M4 19V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14"/><path d="M4 19h16"/><path d="M8 15v-4"/><path d="M12 15V8"/><path d="M16 15v-6"/>'),
|
||||
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
|
||||
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
|
||||
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
||||
|
||||
@@ -10,7 +10,7 @@ import LoginRouteView from '../views/LoginRouteView.vue'
|
||||
import SetupRouteView from '../views/SetupRouteView.vue'
|
||||
|
||||
const appChildRoutes = appViews
|
||||
.filter((view) => view !== 'requests' && view !== 'documents')
|
||||
.filter((view) => view !== 'documents')
|
||||
.map((view) => ({
|
||||
path: view,
|
||||
name: `app-${view}`,
|
||||
@@ -71,21 +71,24 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/app/requests',
|
||||
name: 'app-requests',
|
||||
component: AppShellRouteView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
appView: 'requests'
|
||||
}
|
||||
redirect: { name: 'app-documents' }
|
||||
},
|
||||
{
|
||||
path: '/app/requests/:requestId',
|
||||
name: 'app-request-detail',
|
||||
component: AppShellRouteView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
appView: 'requests'
|
||||
}
|
||||
redirect: (to) => ({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: to.params.requestId },
|
||||
query: to.query,
|
||||
hash: to.hash
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/app/approval',
|
||||
redirect: { name: 'app-documents' }
|
||||
},
|
||||
{
|
||||
path: '/app/archive',
|
||||
redirect: { name: 'app-documents' }
|
||||
},
|
||||
{
|
||||
path: '/app/logs/:logKind/:logId',
|
||||
|
||||
@@ -220,6 +220,14 @@ export function setRiskRuleAssetEnabled(assetId, enabled, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function setRiskRuleAssetLevel(assetId, riskLevel, options = {}) {
|
||||
return apiRequest(`/agent-assets/${assetId}/risk-rule-level`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ risk_level: riskLevel }),
|
||||
headers: buildWriteHeaders(options)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
|
||||
return apiRequest(
|
||||
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchOntologyParse(payload) {
|
||||
export function fetchOntologyParse(payload, options = {}) {
|
||||
return apiRequest('/ontology/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(payload),
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'documents',
|
||||
'requests',
|
||||
'approval',
|
||||
'archive',
|
||||
'policies',
|
||||
'audit',
|
||||
export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'documents',
|
||||
'budget',
|
||||
'policies',
|
||||
'audit',
|
||||
'logs',
|
||||
'employees',
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'requests', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver', 'finance', 'executive'],
|
||||
archive: ['finance', 'executive', 'auditor'],
|
||||
audit: ['auditor', 'finance'],
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
budget: ['finance', 'executive'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
@@ -48,18 +45,22 @@ export function isExecutiveUser(user) {
|
||||
return normalizedRoleCodes(user).includes('executive')
|
||||
}
|
||||
|
||||
export function canManageExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
export function canManageExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canDeleteArchivedExpenseClaims(user) {
|
||||
return Boolean(user?.isAdmin)
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
@@ -72,14 +73,18 @@ export function canApproveLeaderExpenseClaims(user) {
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isManagerUser(user)) {
|
||||
return true
|
||||
}
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isManagerUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
|
||||
return true
|
||||
|
||||
105
web/src/utils/applicationApproval.js
Normal file
105
web/src/utils/applicationApproval.js
Normal file
@@ -0,0 +1,105 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isEmailLike(value) {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
|
||||
}
|
||||
|
||||
function resolveDisplayName(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = normalizeText(value)
|
||||
if (normalized && !isEmailLike(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const nextDate = new Date(value)
|
||||
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = toDate(value)
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function getRiskFlags(request) {
|
||||
const flags = request?.riskFlags || request?.risk_flags_json || []
|
||||
return Array.isArray(flags) ? flags : []
|
||||
}
|
||||
|
||||
function getLatestEvent(events) {
|
||||
const sortedEvents = events
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
|
||||
.filter((item) => item.eventDate)
|
||||
.sort((left, right) => left.eventDate.getTime() - right.eventDate.getTime())
|
||||
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
|
||||
}
|
||||
|
||||
export function findLeaderApprovalEvent(request) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(request).filter((flag) => {
|
||||
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)
|
||||
return (
|
||||
source === 'manual_approval'
|
||||
&& (
|
||||
eventType === 'expense_application_approval'
|
||||
|| previousStage.includes('直属领导')
|
||||
|| previousStage.includes('领导审批')
|
||||
|| nextStage.includes('财务')
|
||||
|| nextStage.includes('审批完成')
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function buildLeaderApprovalInfo(request) {
|
||||
const event = findLeaderApprovalEvent(request)
|
||||
if (!event) {
|
||||
return {
|
||||
opinion: '',
|
||||
operator: '',
|
||||
time: '',
|
||||
generatedDraftClaimNo: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
opinion: normalizeText(event.opinion) || normalizeText(event.message),
|
||||
operator: resolveDisplayName(
|
||||
event.operator,
|
||||
event.operator_name,
|
||||
event.operatorName,
|
||||
request?.profileManager,
|
||||
request?.managerName
|
||||
),
|
||||
time: formatDateTime(event.created_at || event.createdAt),
|
||||
generatedDraftClaimNo: normalizeText(event.generated_draft_claim_no || event.generatedDraftClaimNo)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGeneratedDraftClaimNo(responsePayload) {
|
||||
const event = findLeaderApprovalEvent({
|
||||
riskFlags: responsePayload?.risk_flags_json || responsePayload?.riskFlags || []
|
||||
})
|
||||
return normalizeText(event?.generated_draft_claim_no || event?.generatedDraftClaimNo)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const APPLICATION_FIELD_PREFILLS = {
|
||||
reason: '事由:',
|
||||
days: '天数:',
|
||||
transport_mode: '出行方式:',
|
||||
amount: '预计总费用:'
|
||||
amount: '用户预估费用:'
|
||||
}
|
||||
|
||||
export function resolveSuggestedActionPrefill(action = {}) {
|
||||
|
||||
@@ -34,6 +34,7 @@ function isApplicationDocumentRequest(request) {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const STORAGE_KEY = 'x-financial.documents.viewed'
|
||||
const SCOPE_STORAGE_KEY = 'x-financial.documents.scope'
|
||||
export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT = 'x-financial.documents.viewed-change'
|
||||
|
||||
function getStorage() {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
@@ -30,6 +31,10 @@ export function writeViewedDocumentKeys(keys, storage = getStorage()) {
|
||||
}
|
||||
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys).filter(Boolean)))
|
||||
|
||||
if (typeof window !== 'undefined' && storage === window.localStorage) {
|
||||
window.dispatchEvent(new CustomEvent(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT))
|
||||
}
|
||||
}
|
||||
|
||||
export function readDocumentScope(fallback, allowedScopes = [], storage = getStorage()) {
|
||||
|
||||
47
web/src/utils/documentCenterRows.js
Normal file
47
web/src/utils/documentCenterRows.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
const ARCHIVED_CLAIM_STATUSES = new Set(['approved', 'completed', 'paid'])
|
||||
|
||||
function isArchivedRequestPayload(request) {
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedStatus = String(request.status || '').trim().toLowerCase()
|
||||
const stage = String(request.approval_stage || request.approvalStage || '').trim()
|
||||
|
||||
if (stage === '归档入账' || stage === 'completed') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (stage.includes('归档') || stage.includes('入账')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
isApplicationRequestLike(request)
|
||||
&& ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& ['审批完成', '申请归档'].includes(stage)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& (stage === '' || stage === '归档入账' || stage === 'completed')
|
||||
}
|
||||
|
||||
export function isArchivedDocumentRow(row) {
|
||||
if (!row) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (row.archived === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isArchivedRequestPayload(row.rawRequest || row)
|
||||
}
|
||||
|
||||
export function excludeArchivedDocumentRows(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
|
||||
}
|
||||
22
web/src/utils/documentClassification.js
Normal file
22
web/src/utils/documentClassification.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export function isApplicationRequestLike(value) {
|
||||
const explicitType = String(
|
||||
value?.documentTypeCode
|
||||
|| value?.document_type_code
|
||||
|| value?.documentType
|
||||
|| value?.document_type
|
||||
|| ''
|
||||
).trim()
|
||||
const claimNo = String(value?.claim_no || value?.claimNo || value?.documentNo || value?.id || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const typeCode = String(value?.typeCode || value?.expense_type || value?.expenseType || '').trim()
|
||||
|
||||
return (
|
||||
explicitType === 'application'
|
||||
|| explicitType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
)
|
||||
}
|
||||
106
web/src/utils/expenseApplicationDetail.js
Normal file
106
web/src/utils/expenseApplicationDetail.js
Normal file
@@ -0,0 +1,106 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isProvided(value) {
|
||||
const text = normalizeText(value)
|
||||
return Boolean(text) && !['待补充', '暂无', '无', '未知'].includes(text)
|
||||
}
|
||||
|
||||
function resolveApplicationDetailPayload(request = {}) {
|
||||
const flags = Array.isArray(request.risk_flags_json)
|
||||
? request.risk_flags_json
|
||||
: Array.isArray(request.riskFlags)
|
||||
? request.riskFlags
|
||||
: []
|
||||
const detailFlag = flags.find((flag) =>
|
||||
flag &&
|
||||
typeof flag === 'object' &&
|
||||
normalizeText(flag.source) === 'application_detail'
|
||||
)
|
||||
const detail = detailFlag?.application_detail || detailFlag?.applicationDetail || {}
|
||||
return detail && typeof detail === 'object' ? detail : {}
|
||||
}
|
||||
|
||||
function pickDetailValue(detail, request, keys = [], fallback = '') {
|
||||
for (const key of keys) {
|
||||
const value = normalizeText(detail[key] ?? request[key])
|
||||
if (isProvided(value)) return value
|
||||
}
|
||||
return normalizeText(fallback)
|
||||
}
|
||||
|
||||
export function buildApplicationDetailFactItems(request = {}) {
|
||||
const detail = resolveApplicationDetailPayload(request)
|
||||
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
|
||||
const rows = [
|
||||
{
|
||||
key: 'application_type',
|
||||
label: '申请类型',
|
||||
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: '发生时间',
|
||||
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: '地点',
|
||||
value: pickDetailValue(detail, request, ['location', 'sceneTarget', 'city'], request.sceneTarget)
|
||||
},
|
||||
{
|
||||
key: 'reason',
|
||||
label: '事由',
|
||||
value: pickDetailValue(detail, request, ['reason'], request.reason)
|
||||
},
|
||||
{
|
||||
key: 'days',
|
||||
label: '天数',
|
||||
value: pickDetailValue(detail, request, ['days'])
|
||||
},
|
||||
{
|
||||
key: 'transport_mode',
|
||||
label: '出行方式',
|
||||
value: pickDetailValue(detail, request, ['transport_mode'])
|
||||
},
|
||||
{
|
||||
key: 'grade',
|
||||
label: '职级',
|
||||
value: pickDetailValue(detail, request, ['grade', 'profileGrade', 'employee_grade'], request.profileGrade),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'lodging_daily_cap',
|
||||
label: '住宿上限/天',
|
||||
value: pickDetailValue(detail, request, ['lodging_daily_cap']),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'subsidy_daily_cap',
|
||||
label: '补贴标准/天',
|
||||
value: pickDetailValue(detail, request, ['subsidy_daily_cap']),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'transport_policy',
|
||||
label: '交通费用口径',
|
||||
value: pickDetailValue(detail, request, ['transport_policy'], '车票、机票暂无实时价格接口,按真实票据实报实销')
|
||||
},
|
||||
{
|
||||
key: 'policy_estimate',
|
||||
label: '规则测算参考',
|
||||
value: pickDetailValue(detail, request, ['policy_estimate']),
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '用户预估费用',
|
||||
value: pickDetailValue(detail, request, ['amount'], amountDisplay),
|
||||
highlight: true,
|
||||
emphasis: true
|
||||
}
|
||||
]
|
||||
|
||||
return rows.filter((row) => isProvided(row.value))
|
||||
}
|
||||
@@ -47,6 +47,8 @@ const PROMPT_FIELD_LABELS = [
|
||||
'出行方式',
|
||||
'交通方式',
|
||||
'交通工具',
|
||||
'用户预估费用',
|
||||
'预估费用',
|
||||
'预计总费用',
|
||||
'预计费用',
|
||||
'预计金额',
|
||||
@@ -186,10 +188,18 @@ export function expandApplicationTimeWithDays(timeText, days = 0) {
|
||||
return `${formatApplicationDate(startDate)} 至 ${formatApplicationDate(endDate)}`
|
||||
}
|
||||
|
||||
function normalizeApplicationTimeCandidate(value) {
|
||||
const text = String(value || '').trim().replace(/^[,,、。;;\s]+/, '')
|
||||
if (!text) return ''
|
||||
if (/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/.test(text)) return text
|
||||
if (/今天|明天|后天|本周|下周|上周|本月|下月|月底|月初/.test(text)) return text
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveApplicationTimeRange(ontology, prompt) {
|
||||
const range = ontology?.time_range || {}
|
||||
const baseTime = resolveTimeRangeText(ontology)
|
||||
|| resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间'])
|
||||
const baseTime = normalizeApplicationTimeCandidate(resolveTimeRangeText(ontology))
|
||||
|| normalizeApplicationTimeCandidate(resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间']))
|
||||
if (range.start_date && range.end_date && range.start_date !== range.end_date) {
|
||||
return `${range.start_date} 至 ${range.end_date}`
|
||||
}
|
||||
@@ -220,9 +230,94 @@ export function resolvePromptField(prompt, labels = []) {
|
||||
return match ? match[1].trim().replace(/[,。;;]$/, '') : ''
|
||||
}
|
||||
|
||||
export function resolveApplicationReason(prompt) {
|
||||
function normalizeApplicationTransportMode(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||||
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
|
||||
return text
|
||||
}
|
||||
|
||||
function cleanupApplicationReasonCandidate(value, location = '') {
|
||||
let text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
|
||||
text = text
|
||||
.replace(/^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[::]\s*/u, '')
|
||||
.replace(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '')
|
||||
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
|
||||
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
|
||||
.replace(/(?:高铁|动车|火车|铁路|列车|飞机|机票|航班|轮船|船票|客轮|渡轮|邮轮)/gu, '')
|
||||
.replace(/[,,、。;;]+/g, ',')
|
||||
.replace(/^\s*(申请|费用申请|业务|本次|去|到|前往|赴)\s*/u, '')
|
||||
.replace(/^[,\s]+|[,\s]+$/g, '')
|
||||
.trim()
|
||||
|
||||
const normalizedLocation = String(location || '').trim()
|
||||
if (normalizedLocation) {
|
||||
const escapedLocation = escapeRegExp(normalizedLocation)
|
||||
text = text
|
||||
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.replace(new RegExp(`^(?:去|到|前往|赴)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
if (!text) return ''
|
||||
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
|
||||
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
|
||||
if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function resolveApplicationLocationText(ontology, prompt) {
|
||||
const locationEntity = resolveEntity(ontology, 'location')
|
||||
return locationEntity?.normalized_value
|
||||
|| locationEntity?.value
|
||||
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点', '目的地'])
|
||||
|| ''
|
||||
}
|
||||
|
||||
export function resolveApplicationReason(prompt, ontology = null) {
|
||||
const location = resolveApplicationLocationText(ontology, prompt)
|
||||
const reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason')
|
||||
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
|
||||
if (entityReason) {
|
||||
return cleanupApplicationReasonCandidate(entityReason, location) || entityReason
|
||||
}
|
||||
|
||||
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
|
||||
return labeled || String(prompt || '').trim()
|
||||
if (labeled) {
|
||||
return cleanupApplicationReasonCandidate(labeled, location) || labeled
|
||||
}
|
||||
|
||||
const candidates = String(prompt || '')
|
||||
.split(/[\n,。;;]+/u)
|
||||
.map((item) => cleanupApplicationReasonCandidate(item, location))
|
||||
.filter(Boolean)
|
||||
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
|
||||
return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || ''
|
||||
}
|
||||
|
||||
function resolveApplicationTransportMode(ontology, prompt) {
|
||||
const transportEntity = resolveEntity(ontology, 'transport_mode')
|
||||
|| resolveEntity(ontology, 'transport')
|
||||
const fromEntity = normalizeApplicationTransportMode(
|
||||
transportEntity?.normalized_value || transportEntity?.value || ''
|
||||
)
|
||||
if (fromEntity) return fromEntity
|
||||
|
||||
const labeled = resolvePromptField(prompt, ['出行方式', '交通方式', '交通工具'])
|
||||
const fromLabel = normalizeApplicationTransportMode(labeled)
|
||||
if (fromLabel) return fromLabel
|
||||
|
||||
const text = String(prompt || '')
|
||||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||||
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
||||
@@ -260,17 +355,16 @@ export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
||||
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
|
||||
const expenseTypeCode = resolveExpenseTypeCode(ontology)
|
||||
const amount = resolveApplicationAmount(ontology)
|
||||
const locationEntity = resolveEntity(ontology, 'location')
|
||||
const documentTypeEntity = resolveEntity(ontology, 'document_type')
|
||||
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
|
||||
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
|
||||
const timeRange = resolveApplicationTimeRange(ontology, prompt)
|
||||
|| '待补充'
|
||||
const location = locationEntity?.normalized_value
|
||||
|| locationEntity?.value
|
||||
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点'])
|
||||
const location = resolveApplicationLocationText(ontology, prompt)
|
||||
|| '待补充'
|
||||
const reason = resolveApplicationReason(prompt) || '待补充'
|
||||
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
|
||||
const days = resolvePromptDays(prompt)
|
||||
const transportMode = resolveApplicationTransportMode(ontology, prompt)
|
||||
|
||||
const fields = {
|
||||
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
||||
@@ -284,6 +378,8 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
||||
timeRange,
|
||||
location,
|
||||
reason,
|
||||
days: days ? `${days}天` : '',
|
||||
transportMode,
|
||||
applicant: currentUser.name || currentUser.username || '当前用户',
|
||||
department: currentUser.department || currentUser.departmentName || '待补充',
|
||||
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
|
||||
|
||||
528
web/src/utils/expenseApplicationPreview.js
Normal file
528
web/src/utils/expenseApplicationPreview.js
Normal file
@@ -0,0 +1,528 @@
|
||||
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
||||
|
||||
const APPLICATION_SESSION_TYPE = 'application'
|
||||
const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/
|
||||
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||
{ key: 'applicationType', label: '申请类型' },
|
||||
{ key: 'grade', label: '职级', highlight: true },
|
||||
{ key: 'time', label: '发生时间' },
|
||||
{ key: 'location', label: '地点' },
|
||||
{ key: 'reason', label: '事由' },
|
||||
{ key: 'days', label: '天数' },
|
||||
{ key: 'transportMode', label: '出行方式' },
|
||||
{ key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
|
||||
{ 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 }
|
||||
]
|
||||
|
||||
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||||
|
||||
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||||
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '车票、机票暂无实时价格接口,按真实票据实报实销'
|
||||
|
||||
function compactText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function resolveFirstMatch(text, patterns = []) {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern)
|
||||
const value = String(match?.groups?.value || match?.[1] || '').trim()
|
||||
if (value) return value.replace(/[,。;;]$/, '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeDateText(value) {
|
||||
return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const match = normalizeDateText(value).match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
|
||||
if (!match) return null
|
||||
const [, year, month, day] = match
|
||||
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function buildEndDateFromDays(startText, daysText = '') {
|
||||
const days = Number(String(daysText || '').replace(/[^\d]/g, ''))
|
||||
const start = parseIsoDate(startText)
|
||||
if (!days || !start) return ''
|
||||
const end = new Date(start.getTime())
|
||||
end.setUTCDate(end.getUTCDate() + days)
|
||||
return formatIsoDate(end)
|
||||
}
|
||||
|
||||
function resolveDaysFromDateRange(rangeText) {
|
||||
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
|
||||
if (!match) return ''
|
||||
const start = parseIsoDate(match[1])
|
||||
const end = parseIsoDate(match[2])
|
||||
if (!start || !end) return ''
|
||||
const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
|
||||
return diffDays > 0 ? `${diffDays}天` : '1天'
|
||||
}
|
||||
|
||||
function resolveApplicationType(text) {
|
||||
const compact = compactText(text)
|
||||
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
|
||||
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
|
||||
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
|
||||
if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
|
||||
if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
|
||||
if (/培训|课程|学习/.test(compact)) return '培训费用申请'
|
||||
return '费用申请'
|
||||
}
|
||||
|
||||
function resolveApplicationAmount(text) {
|
||||
const compact = compactText(text)
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)?/u,
|
||||
/(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)/u
|
||||
])
|
||||
if (labeled) return `${labeled}元`
|
||||
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveCurrentUserGrade(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.grade
|
||||
|| currentUser.employeeGrade
|
||||
|| currentUser.employee_grade
|
||||
|| currentUser.profileGrade
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function parseApplicationDaysValue(value) {
|
||||
const match = String(value || '').match(/\d+/)
|
||||
const days = match ? Number(match[0]) : 0
|
||||
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
|
||||
}
|
||||
|
||||
function parseMoneyNumber(value) {
|
||||
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
|
||||
const amount = Number(normalized)
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
function formatPolicyMoney(value) {
|
||||
const amount = parseMoneyNumber(value)
|
||||
if (amount === null) return String(value || '').trim()
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDailyPolicyMoney(value) {
|
||||
const display = formatPolicyMoney(value)
|
||||
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
|
||||
function buildTransportPolicyText(transportMode) {
|
||||
const mode = String(transportMode || '').trim()
|
||||
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
return `${mode}票据暂无实时价格接口,按真实票据实报实销`
|
||||
}
|
||||
|
||||
function ensureApplicationPolicyFields(fields = {}) {
|
||||
const nextFields = { ...fields }
|
||||
if (!String(nextFields.lodgingDailyCap || '').trim()) {
|
||||
nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
if (!String(nextFields.subsidyDailyCap || '').trim()) {
|
||||
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
|
||||
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode)
|
||||
}
|
||||
if (!String(nextFields.policyEstimate || '').trim()) {
|
||||
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
return nextFields
|
||||
}
|
||||
|
||||
function resolveApplicationDays(text) {
|
||||
const value = resolveFirstMatch(text, [
|
||||
/(?:出差|申请)?(?<value>\d+)\s*天/u,
|
||||
/(?<value>\d+)\s*(?:个)?工作日/u
|
||||
])
|
||||
return value ? `${value}天` : ''
|
||||
}
|
||||
|
||||
function resolveApplicationTime(text, daysText = '') {
|
||||
const range = text.match(
|
||||
/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—|–|--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
)
|
||||
if (range) {
|
||||
return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
|
||||
}
|
||||
|
||||
const single = resolveFirstMatch(text, [
|
||||
/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
|
||||
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
])
|
||||
if (!single) return ''
|
||||
const normalized = normalizeDateText(single)
|
||||
const endDate = buildEndDateFromDays(normalized, daysText)
|
||||
return endDate ? `${normalized} 至 ${endDate}` : normalized
|
||||
}
|
||||
|
||||
function resolveApplicationLocation(text) {
|
||||
return resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n]+)/u,
|
||||
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
|
||||
])
|
||||
}
|
||||
|
||||
function resolveApplicationTransportMode(text) {
|
||||
const compact = compactText(text)
|
||||
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compact)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
|
||||
return ''
|
||||
}
|
||||
|
||||
function stripKnownContextFromReason(value, context = {}) {
|
||||
const location = String(context.location || '').trim()
|
||||
let cleaned = String(value || '')
|
||||
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
.replace(/(?:出差|申请)?\d+\s*天/gu, '')
|
||||
.replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[::]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
|
||||
.replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
|
||||
.replace(/[,,、。;;]+/g, ',')
|
||||
.replace(/^\s*(去|到|前往)/u, '')
|
||||
.replace(/^[,\s]+|[,\s]+$/g, '')
|
||||
.trim()
|
||||
|
||||
if (location) {
|
||||
const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
cleaned = cleaned
|
||||
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
function pickBusinessReasonSegment(text) {
|
||||
const segments = String(text || '')
|
||||
.split(/[,,、。;;\n]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
|
||||
}
|
||||
|
||||
function resolveApplicationReason(text, context = {}) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?<value>[^,。;;\n]+)/u
|
||||
])
|
||||
if (labeled) return stripKnownContextFromReason(labeled, context)
|
||||
const cleaned = String(text || '')
|
||||
.replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
|
||||
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
||||
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
||||
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
||||
return withoutContext
|
||||
}
|
||||
|
||||
function isApplicationPreviewValueProvided(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
|
||||
}
|
||||
|
||||
function resolveProvidedValue(value, fallback = '') {
|
||||
return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
|
||||
}
|
||||
|
||||
function normalizeApplicationTypeLabel(value, fallback = '') {
|
||||
const label = String(value || '').trim()
|
||||
if (!label || label === '其他费用') return fallback || '费用申请'
|
||||
if (label.endsWith('费用申请') || label.endsWith('申请')) return label
|
||||
if (label.endsWith('费用')) return `${label}申请`
|
||||
if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
function normalizeTransportModeOption(value, fallback = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||||
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
|
||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
|
||||
}
|
||||
|
||||
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
const numericAmount = Number(fields.amount || 0)
|
||||
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||||
return `${numericAmount}元`
|
||||
}
|
||||
|
||||
const display = String(fields.amountDisplay || '').trim()
|
||||
if (display && display !== '待补充') {
|
||||
const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
|
||||
return normalized.endsWith('元') ? normalized : `${normalized}元`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function buildMissingFields(fields) {
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
|
||||
.filter((item) => item.key !== 'applicationType' && item.required !== false)
|
||||
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
|
||||
.map((item) => item.label)
|
||||
}
|
||||
|
||||
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
const days = parseApplicationDaysValue(fields.days)
|
||||
const location = String(fields.location || '').trim()
|
||||
const grade = String(fields.grade || resolveCurrentUserGrade(currentUser)).trim()
|
||||
const applicationType = String(fields.applicationType || '').trim()
|
||||
const transportMode = String(fields.transportMode || '').trim()
|
||||
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
|
||||
|
||||
if (!shouldEstimate || !days || !location) {
|
||||
return {
|
||||
canCalculate: false,
|
||||
reason: '缺少地点或天数',
|
||||
payload: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canCalculate: true,
|
||||
reason: '',
|
||||
payload: {
|
||||
days,
|
||||
location,
|
||||
grade
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
|
||||
const fields = { ...(preview?.fields || {}) }
|
||||
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
|
||||
const hotelRate = formatPolicyMoney(result?.hotel_rate)
|
||||
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()
|
||||
|
||||
return normalizeApplicationPreview({
|
||||
...preview,
|
||||
fields: {
|
||||
...fields,
|
||||
grade,
|
||||
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
||||
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
||||
transportPolicy: buildTransportPolicyText(fields.transportMode),
|
||||
policyEstimate: `住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天,不含交通票据)`,
|
||||
matchedCity,
|
||||
ruleName: String(result?.rule_name || '').trim(),
|
||||
ruleVersion: String(result?.rule_version || '').trim(),
|
||||
hotelAmount: hotelAmount ? `${hotelAmount}元` : '',
|
||||
allowanceAmount: allowanceAmount ? `${allowanceAmount}元` : '',
|
||||
policyTotalAmount: totalAmount ? `${totalAmount}元` : ''
|
||||
},
|
||||
policyEstimate: {
|
||||
...result,
|
||||
grade,
|
||||
matchedCity
|
||||
},
|
||||
policyEstimateStatus: 'completed'
|
||||
})
|
||||
}
|
||||
|
||||
export function applyApplicationPolicyEstimateError(preview = {}, error = null, currentUser = {}) {
|
||||
const fields = { ...(preview?.fields || {}) }
|
||||
const message = String(error?.message || error || '').trim()
|
||||
return normalizeApplicationPreview({
|
||||
...preview,
|
||||
fields: {
|
||||
...fields,
|
||||
grade: fields.grade || resolveCurrentUserGrade(currentUser),
|
||||
transportPolicy: buildTransportPolicyText(fields.transportMode),
|
||||
policyEstimate: message ? `规则中心暂未完成测算:${message}` : APPLICATION_POLICY_PENDING_TEXT
|
||||
},
|
||||
policyEstimateStatus: message ? 'failed' : 'pending'
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
|
||||
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false
|
||||
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) return false
|
||||
|
||||
const compact = compactText(rawText)
|
||||
if (!compact || APPLICATION_QUERY_PATTERN.test(compact)) return false
|
||||
return APPLICATION_CREATE_PATTERN.test(compact)
|
||||
}
|
||||
|
||||
export function normalizeApplicationPreview(preview = {}) {
|
||||
const fields = ensureApplicationPolicyFields(preview?.fields || {})
|
||||
const missingFields = buildMissingFields(fields)
|
||||
return {
|
||||
...preview,
|
||||
fields,
|
||||
missingFields,
|
||||
readyToSubmit: missingFields.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModelRefinedApplicationPreview(localPreview = {}, ontology = {}, rawText = '', currentUser = {}) {
|
||||
const currentFields = localPreview?.fields || {}
|
||||
const ontologyFields = buildApplicationFieldsFromOntology(ontology || {}, rawText, currentUser)
|
||||
const parseStrategy = String(ontology?.parse_strategy || '').trim()
|
||||
const refinedFields = {
|
||||
...currentFields,
|
||||
applicationType: normalizeApplicationTypeLabel(
|
||||
ontologyFields.expenseTypeLabel,
|
||||
currentFields.applicationType
|
||||
),
|
||||
time: resolveProvidedValue(ontologyFields.timeRange, currentFields.time),
|
||||
location: resolveProvidedValue(ontologyFields.location, currentFields.location),
|
||||
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
|
||||
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
|
||||
transportMode: normalizeTransportModeOption(
|
||||
ontologyFields.transportMode,
|
||||
currentFields.transportMode
|
||||
),
|
||||
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
|
||||
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
|
||||
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
|
||||
department: resolveProvidedValue(ontologyFields.department, currentFields.department)
|
||||
}
|
||||
|
||||
return normalizeApplicationPreview({
|
||||
...localPreview,
|
||||
sourceText: String(rawText || localPreview.sourceText || '').trim(),
|
||||
fields: refinedFields,
|
||||
modelRefined: true,
|
||||
parseStrategy,
|
||||
modelReviewStatus: parseStrategy === 'llm_primary' ? 'completed' : 'fallback'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildApplicationPreviewRows(preview = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.map((item) => {
|
||||
const rawValue = fields[item.key]
|
||||
const value = String(rawValue || '').trim() || '待补充'
|
||||
return {
|
||||
...item,
|
||||
value,
|
||||
editable: item.editable !== false,
|
||||
highlight: Boolean(item.highlight),
|
||||
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildApplicationPreviewSubmitText(preview = {}) {
|
||||
const rows = buildApplicationPreviewRows(preview)
|
||||
return [
|
||||
'费用申请确认提交',
|
||||
...rows.map((row) => `${row.label}:${row.value}`),
|
||||
'',
|
||||
'确认提交'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildLocalApplicationPreview(rawText, currentUser = {}) {
|
||||
const sourceText = String(rawText || '').trim()
|
||||
const explicitDays = resolveApplicationDays(sourceText)
|
||||
const time = resolveApplicationTime(sourceText, explicitDays)
|
||||
const days = explicitDays || resolveDaysFromDateRange(time)
|
||||
const location = resolveApplicationLocation(sourceText)
|
||||
const fields = {
|
||||
applicationType: resolveApplicationType(sourceText),
|
||||
time,
|
||||
location,
|
||||
reason: resolveApplicationReason(sourceText, { location }),
|
||||
days,
|
||||
transportMode: resolveApplicationTransportMode(sourceText),
|
||||
amount: resolveApplicationAmount(sourceText),
|
||||
grade: resolveCurrentUserGrade(currentUser),
|
||||
applicant: currentUser.name || currentUser.username || '当前用户',
|
||||
department: currentUser.department || currentUser.departmentName || '待补充'
|
||||
}
|
||||
|
||||
return normalizeApplicationPreview({
|
||||
sourceText,
|
||||
fields,
|
||||
modelReviewStatus: 'local'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildApplicationTemplatePreview(currentUser = {}) {
|
||||
return normalizeApplicationPreview({
|
||||
sourceText: '快速发起申请',
|
||||
fields: {
|
||||
applicationType: '费用申请',
|
||||
time: '',
|
||||
location: '',
|
||||
reason: '',
|
||||
days: '',
|
||||
transportMode: '',
|
||||
amount: '',
|
||||
grade: resolveCurrentUserGrade(currentUser),
|
||||
applicant: currentUser.name || currentUser.username || '当前用户',
|
||||
department: currentUser.department || currentUser.departmentName || '待补充'
|
||||
},
|
||||
modelReviewStatus: 'template'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildLocalApplicationPreviewMessage(preview) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
|
||||
return [
|
||||
modelReviewStatus === 'completed'
|
||||
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'
|
||||
: modelReviewStatus === 'fallback'
|
||||
? '模型复核没有返回稳定结果,我已先按规则兜底整理成下方表格。请重点核查识别结果;点击对应行即可直接编辑。'
|
||||
: modelReviewStatus === 'failed'
|
||||
? '模型复核暂时失败,我先保留一份临时核对表,方便您核查和补充信息。点击对应行即可直接编辑。'
|
||||
: modelReviewStatus === 'template'
|
||||
? '我已为你准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。'
|
||||
: '我先整理出下方表格,请核查识别结果。点击对应行即可直接编辑。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildApplicationPreviewFooterMessage(preview) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
|
||||
if (missingFields.length) {
|
||||
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
|
||||
}
|
||||
if (modelReviewStatus === 'fallback') {
|
||||
return '当前结果仅完成规则兜底复核,暂不直接提交。请核查表格内容,或稍后重新发起模型复核。'
|
||||
}
|
||||
if (modelReviewStatus === 'failed') {
|
||||
return '当前结果仅作为临时预览,暂不直接提交。请稍后重试,或补充更明确的信息后再提交。'
|
||||
}
|
||||
|
||||
return '请核对表格信息无误,确认无误后点击 [确认](#application-submit) 提交至审批流程。'
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
export function isArchivedExpenseClaim(claim) {
|
||||
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
|
||||
const status = String(claim?.status || '').trim().toLowerCase()
|
||||
@@ -10,5 +12,9 @@ export function isArchivedExpenseClaim(claim) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isApplicationRequestLike(claim) && ['审批完成', '申请归档'].includes(stage)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !stage || stage === '归档入账' || stage === 'completed'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
const REQUEST_TYPE_META = {
|
||||
travel: {
|
||||
label: '差旅费',
|
||||
@@ -250,6 +252,29 @@ function resolveDisplayName(...values) {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isArchivedRequestView(request) {
|
||||
const status = String(request?.status || '').trim().toLowerCase()
|
||||
const approvalKey = String(request?.approvalKey || '').trim().toLowerCase()
|
||||
const rawStage = String(request?.approval_stage || request?.approvalStage || '').trim()
|
||||
const displayStage = String(request?.workflowNode || request?.node || '').trim()
|
||||
const stage = rawStage || displayStage
|
||||
|
||||
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档') || stage.includes('入账')) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
isApplicationRequestLike(request)
|
||||
&& ['approved', 'completed', 'paid'].includes(status)
|
||||
&& ['审批完成', '申请归档'].includes(stage)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (['approved', 'completed', 'paid'].includes(status)) {
|
||||
return rawStage === '' || rawStage === '归档入账' || rawStage === 'completed'
|
||||
}
|
||||
return approvalKey === 'completed'
|
||||
}
|
||||
|
||||
export function normalizeRequestForUi(request) {
|
||||
if (!request) {
|
||||
return null
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
<main
|
||||
class="main"
|
||||
:class="{
|
||||
'overview-main': activeView === 'overview',
|
||||
'workbench-main': activeView === 'workbench',
|
||||
'documents-main': activeView === 'documents',
|
||||
'requests-main': activeView === 'requests',
|
||||
'approval-main': activeView === 'approval',
|
||||
'archive-main': activeView === 'archive',
|
||||
'policies-main': activeView === 'policies',
|
||||
'overview-main': activeView === 'overview',
|
||||
'workbench-main': activeView === 'workbench',
|
||||
'documents-main': activeView === 'documents',
|
||||
'budget-main': activeView === 'budget',
|
||||
'policies-main': activeView === 'policies',
|
||||
'audit-main': activeView === 'audit',
|
||||
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
|
||||
'logs-main': activeView === 'logs',
|
||||
@@ -57,7 +55,7 @@
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
:compact="activeView === 'overview'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
@@ -68,11 +66,9 @@
|
||||
<section
|
||||
class="workarea"
|
||||
:class="{
|
||||
'requests-workarea': activeView === 'requests',
|
||||
'documents-workarea': activeView === 'documents',
|
||||
'approval-workarea': activeView === 'approval',
|
||||
'archive-workarea': activeView === 'archive',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'documents-workarea': activeView === 'documents',
|
||||
'budget-workarea': activeView === 'budget',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit',
|
||||
'logs-workarea': activeView === 'logs',
|
||||
'employees-workarea': activeView === 'employees',
|
||||
@@ -92,11 +88,11 @@
|
||||
@open-assistant="openSmartEntry"
|
||||
/>
|
||||
|
||||
<TravelRequestDetailView
|
||||
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
|
||||
:request="selectedRequest"
|
||||
:back-label="activeView === 'documents' ? '返回单据中心' : '返回报销列表'"
|
||||
@back-to-requests="closeRequestDetail"
|
||||
<TravelRequestDetailView
|
||||
v-else-if="activeView === 'documents' && detailMode && selectedRequest"
|
||||
:request="selectedRequest"
|
||||
back-label="返回单据中心"
|
||||
@back-to-requests="closeRequestDetail"
|
||||
@open-assistant="openSmartEntry"
|
||||
@request-updated="handleRequestUpdated"
|
||||
@request-deleted="handleRequestDeleted"
|
||||
@@ -115,22 +111,8 @@
|
||||
@summary-change="documentSummary = $event"
|
||||
/>
|
||||
|
||||
<RequestsView
|
||||
v-else-if="activeView === 'requests'"
|
||||
:filtered-requests="filteredRequests"
|
||||
:has-data="requests.length > 0"
|
||||
:loading="requestsLoading"
|
||||
:error="requestsError"
|
||||
@ask="openRequestDetail"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
@reload="reloadRequests"
|
||||
@create-request="openTravelCreate"
|
||||
/>
|
||||
|
||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||
<ArchiveCenterView v-else-if="activeView === 'archive'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<BudgetCenterView v-else-if="activeView === 'budget'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
|
||||
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
||||
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
|
||||
@@ -145,11 +127,12 @@
|
||||
:initial-prompt="smartEntryContext.prompt"
|
||||
:initial-files="smartEntryContext.files"
|
||||
:initial-conversation="smartEntryContext.conversation"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||
:reopen-token="smartEntryRevealToken"
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -162,13 +145,11 @@ import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
import DocumentsCenterView from './DocumentsCenterView.vue'
|
||||
import RequestsView from './RequestsView.vue'
|
||||
import ApprovalCenterView from './ApprovalCenterView.vue'
|
||||
import ArchiveCenterView from './ArchiveCenterView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
import DocumentsCenterView from './DocumentsCenterView.vue'
|
||||
import BudgetCenterView from './BudgetCenterView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import AuditView from './AuditView.vue'
|
||||
import LogsView from './LogsView.vue'
|
||||
import LogDetailView from './LogDetailView.vue'
|
||||
@@ -222,9 +203,10 @@ const {
|
||||
search,
|
||||
selectedRequest,
|
||||
smartEntryContext,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntryRevealToken,
|
||||
smartEntrySessionId,
|
||||
toast,
|
||||
topBarView
|
||||
} = useAppShell()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
160
web/src/views/BudgetCenterView.vue
Normal file
160
web/src/views/BudgetCenterView.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<section class="budget-center-page">
|
||||
<header class="budget-local-head">
|
||||
<h2>预算管理</h2>
|
||||
</header>
|
||||
|
||||
<section class="budget-summary-grid" aria-label="预算概览">
|
||||
<article v-for="metric in budgetMetrics" :key="metric.label" class="budget-summary-card">
|
||||
<span class="summary-icon" :class="metric.tone">
|
||||
<i :class="metric.icon"></i>
|
||||
</span>
|
||||
<div>
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<em>{{ metric.note }}</em>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="budget-filter-bar">
|
||||
<label>
|
||||
<span>预算周期</span>
|
||||
<select v-model="filters.period">
|
||||
<option v-for="period in periods" :key="period">{{ period }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>费用类型</span>
|
||||
<select v-model="filters.expenseType">
|
||||
<option v-for="type in expenseTypes" :key="type">{{ type }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model="filters.status">
|
||||
<option v-for="status in statuses" :key="status">{{ status }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="budget-primary-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新建预算</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="budget-work-grid">
|
||||
<aside class="budget-department-panel">
|
||||
<header>
|
||||
<strong>部门切换</strong>
|
||||
</header>
|
||||
<div class="department-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="departmentKeyword" type="search" placeholder="搜索部门" />
|
||||
</div>
|
||||
<nav class="department-list" aria-label="预算部门">
|
||||
<button
|
||||
v-for="department in visibleDepartments"
|
||||
:key="department.code"
|
||||
type="button"
|
||||
:class="{ active: department.code === activeDepartmentCode }"
|
||||
@click="activeDepartmentCode = department.code"
|
||||
>
|
||||
<i :class="department.icon"></i>
|
||||
<span>{{ department.name }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<article class="budget-table-panel">
|
||||
<header>
|
||||
<strong>当前部门:{{ activeDepartmentName }}</strong>
|
||||
</header>
|
||||
<div class="budget-table-wrap">
|
||||
<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="row in visibleBudgetRows" :key="row.expenseType">
|
||||
<td>{{ row.expenseType }}</td>
|
||||
<td>{{ row.total }}</td>
|
||||
<td>{{ row.used }}</td>
|
||||
<td>{{ row.occupied }}</td>
|
||||
<td>{{ row.left }}</td>
|
||||
<td>
|
||||
<div class="budget-rate">
|
||||
<span>{{ row.rate }}%</span>
|
||||
<div><em :class="row.rateTone" :style="{ width: `${Math.min(row.rate, 100)}%` }"></em></div>
|
||||
</div>
|
||||
</td>
|
||||
<td :class="row.warningTone">{{ row.warningLine }}</td>
|
||||
<td>{{ row.action }}</td>
|
||||
<td>
|
||||
<div class="budget-row-actions">
|
||||
<button type="button">详情</button>
|
||||
<button type="button">编辑</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer class="budget-table-foot">
|
||||
<button type="button" disabled><i class="mdi mdi-chevron-left"></i></button>
|
||||
<button type="button" class="active">1</button>
|
||||
<button type="button" disabled><i class="mdi mdi-chevron-right"></i></button>
|
||||
<select aria-label="每页条数">
|
||||
<option>10 条/页</option>
|
||||
</select>
|
||||
<span>共 {{ visibleBudgetRows.length }} 条</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 budget"></i>预算</span>
|
||||
<span><i class="legend-line used"></i>已发生</span>
|
||||
</div>
|
||||
</header>
|
||||
<BudgetTrendChart
|
||||
:labels="trendData.labels"
|
||||
:budget="trendData.budget"
|
||||
:used="trendData.used"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="budget-alert-panel">
|
||||
<header class="budget-card-head">
|
||||
<strong>预算预警</strong>
|
||||
<button type="button">查看全部</button>
|
||||
</header>
|
||||
<div class="budget-alert-list">
|
||||
<div v-for="alert in warnings" :key="alert.title" class="budget-alert-row">
|
||||
<i :class="alert.tone"></i>
|
||||
<strong>{{ alert.title }}</strong>
|
||||
<span>{{ alert.desc }}</span>
|
||||
<time>{{ alert.date }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/BudgetCenterView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/budget-center-view.css"></style>
|
||||
@@ -264,6 +264,7 @@ import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
||||
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
|
||||
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
||||
import { excludeArchivedDocumentRows, isArchivedDocumentRow } from '../utils/documentCenterRows.js'
|
||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||
|
||||
const DOCUMENT_TYPE_ALL = 'all'
|
||||
@@ -388,9 +389,11 @@ const dateRangeLabel = computed(() => {
|
||||
})
|
||||
|
||||
const ownedRows = computed(() =>
|
||||
props.filteredRequests
|
||||
.map((item) => buildDocumentRow(item, { source: 'owned' }))
|
||||
.filter(Boolean)
|
||||
excludeArchivedDocumentRows(
|
||||
props.filteredRequests
|
||||
.map((item) => buildDocumentRow(item, { source: 'owned' }))
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
|
||||
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
|
||||
@@ -518,7 +521,7 @@ const emptyState = computed(() => {
|
||||
actionIcon: '',
|
||||
tone: 'emerald',
|
||||
artLabel: 'APPLY',
|
||||
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
|
||||
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,10 +536,17 @@ const emptyState = computed(() => {
|
||||
actionIcon: '',
|
||||
tone: 'emerald',
|
||||
artLabel: filtered ? 'FILTER' : 'DOCS',
|
||||
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
|
||||
tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
|
||||
}
|
||||
})
|
||||
|
||||
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
|
||||
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
|
||||
return '申请归档'
|
||||
}
|
||||
return normalized.node || normalized.workflowNode || '财务归档'
|
||||
}
|
||||
|
||||
function buildDocumentRow(request, options = {}) {
|
||||
const normalized = normalizeRequestForUi(request)
|
||||
if (!normalized) {
|
||||
@@ -563,7 +573,7 @@ function buildDocumentRow(request, options = {}) {
|
||||
documentTypeLabel,
|
||||
claimId,
|
||||
documentNo,
|
||||
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
statusGroup,
|
||||
statusLabel,
|
||||
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
|
||||
@@ -598,6 +608,10 @@ function resolveStatusTone(row, statusGroup) {
|
||||
}
|
||||
|
||||
function matchesStatusTab(row, tab) {
|
||||
if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (tab === '全部') return true
|
||||
if (tab === '草稿') return row.statusGroup === 'draft'
|
||||
if (tab === '待提交') return row.statusGroup === 'pending_submit'
|
||||
@@ -730,12 +744,14 @@ async function loadSupportingRows() {
|
||||
])
|
||||
|
||||
if (approvalResult.status === 'fulfilled') {
|
||||
approvalRows.value = Array.isArray(approvalResult.value)
|
||||
? approvalResult.value
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, { source: 'approval' }))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
approvalRows.value = excludeArchivedDocumentRows(
|
||||
Array.isArray(approvalResult.value)
|
||||
? approvalResult.value
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, { source: 'approval' }))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
)
|
||||
} else {
|
||||
approvalRows.value = []
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
class="assistant-layout"
|
||||
:class="{
|
||||
'can-show-insight': hasInsightPanelContent,
|
||||
'has-insight': showInsightPanel
|
||||
'has-insight': hasInsightPanelContent && showInsightPanel
|
||||
}"
|
||||
>
|
||||
<section class="dialog-panel">
|
||||
@@ -109,6 +109,88 @@
|
||||
@click="handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && message.applicationPreview"
|
||||
class="application-preview-table"
|
||||
role="table"
|
||||
aria-label="申请信息核对表"
|
||||
>
|
||||
<div class="application-preview-row head" role="row">
|
||||
<span role="columnheader">字段</span>
|
||||
<span role="columnheader">内容</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in resolveApplicationPreviewRows(message)"
|
||||
:key="`${message.id}-${row.key}`"
|
||||
class="application-preview-row"
|
||||
:class="{
|
||||
missing: row.missing,
|
||||
editable: row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy,
|
||||
highlight: row.highlight
|
||||
}"
|
||||
role="row"
|
||||
:tabindex="row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy ? 0 : -1"
|
||||
:aria-label="row.editable ? `编辑${row.label}` : row.label"
|
||||
@click="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||
<span class="application-preview-value" role="cell">
|
||||
<input
|
||||
v-if="isApplicationPreviewEditing(message, row.key) && resolveApplicationPreviewEditorControl(row.key) !== 'select'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input"
|
||||
type="text"
|
||||
autofocus
|
||||
@click.stop
|
||||
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="commitApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="isApplicationPreviewEditing(message, row.key)"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input application-preview-select"
|
||||
autofocus
|
||||
@click.stop
|
||||
@change="commitApplicationPreviewEditor(message)"
|
||||
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="commitApplicationPreviewEditor(message)"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
<option
|
||||
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<template v-else>
|
||||
<span class="application-preview-text">{{ row.value }}</span>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
type="button"
|
||||
class="application-preview-edit-btn"
|
||||
title="修改内容"
|
||||
aria-label="修改内容"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && message.applicationPreview && buildApplicationPreviewFooterText(message)"
|
||||
class="application-preview-footer message-answer-content message-answer-markdown"
|
||||
v-html="renderMarkdown(buildApplicationPreviewFooterText(message))"
|
||||
@click="handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
|
||||
class="welcome-quick-actions"
|
||||
@@ -577,7 +659,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="travel-calculator-anchor">
|
||||
<div v-if="canShowTravelCalculator" class="travel-calculator-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn composer-side-btn travel-calculator-trigger"
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<div class="detail-grid">
|
||||
<section class="detail-left">
|
||||
<article class="detail-card panel">
|
||||
<article v-if="!isApplicationDocument" class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>附加说明</h3>
|
||||
@@ -133,11 +133,14 @@
|
||||
<article class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>{{ isApplicationDocument ? '申请预算' : '费用明细' }}</h3>
|
||||
<h3 class="detail-card-title-with-icon">
|
||||
<i v-if="isApplicationDocument" class="mdi mdi-file-document-outline"></i>
|
||||
<span>{{ isApplicationDocument ? '申请详情' : '费用明细' }}</span>
|
||||
</h3>
|
||||
<p>
|
||||
{{
|
||||
isApplicationDocument
|
||||
? '展示本次费用申请的预计金额,提交后纳入预算管理口径。'
|
||||
? '展示本次申请的事实信息、职级规则测算和用户预估费用。'
|
||||
: isTravelRequest
|
||||
? '按出行时间逐笔核对票据与差旅规则。'
|
||||
: '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。'
|
||||
@@ -162,13 +165,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isApplicationDocument" class="detail-note readonly">
|
||||
<p>
|
||||
预计总费用:{{ request.amountDisplay }}。该金额用于领导审批和预算管理,无需补充任何报销票据。
|
||||
</p>
|
||||
<div v-if="isApplicationDocument" class="application-detail-facts">
|
||||
<div
|
||||
v-for="item in applicationDetailFactItems"
|
||||
:key="item.key"
|
||||
class="application-detail-fact"
|
||||
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-expense-table">
|
||||
<div v-if="showApplicationLeaderOpinion" class="application-leader-opinion">
|
||||
<div class="application-leader-opinion-head">
|
||||
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
|
||||
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
|
||||
</div>
|
||||
<div v-if="showApplicationLeaderOpinionInput" class="leader-approval-card inline-leader-opinion">
|
||||
<textarea
|
||||
v-model="leaderOpinion"
|
||||
maxlength="500"
|
||||
:required="requiresApprovalOpinion"
|
||||
:placeholder="approvalOpinionPlaceholder"
|
||||
:aria-label="approvalOpinionTitle"
|
||||
></textarea>
|
||||
<div class="leader-opinion-meta">
|
||||
<span>{{ approvalOpinionHint }}</span>
|
||||
<strong>{{ leaderOpinion.length }}/500</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail-note readonly application-leader-opinion-display">
|
||||
<p>{{ leaderApprovalReadonlyText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isApplicationDocument" class="detail-expense-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -452,6 +484,7 @@
|
||||
<textarea
|
||||
v-model="leaderOpinion"
|
||||
maxlength="500"
|
||||
:required="requiresApprovalOpinion"
|
||||
:placeholder="approvalOpinionPlaceholder"
|
||||
:aria-label="approvalOpinionTitle"
|
||||
></textarea>
|
||||
@@ -479,7 +512,7 @@
|
||||
{{ submitBusy ? '提交中' : '提交审批' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="canReturnRequest || canApproveRequest || canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
|
||||
<div v-else-if="canReturnRequest || canApproveRequest || canDeleteRequest" class="approval-action-group" aria-label="单据管理操作">
|
||||
<button
|
||||
v-if="canReturnRequest"
|
||||
class="return-action"
|
||||
@@ -498,10 +531,10 @@
|
||||
@click="handleApproveRequest"
|
||||
>
|
||||
<i class="mdi mdi-check-circle-outline"></i>
|
||||
{{ approveBusy ? '通过中' : '审批通过' }}
|
||||
{{ approveBusy ? approveBusyLabel : approveActionLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageCurrentClaim"
|
||||
v-if="canDeleteRequest"
|
||||
class="reject-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@@ -750,11 +783,11 @@
|
||||
:open="approveConfirmDialogOpen"
|
||||
:badge="approvalConfirmBadge"
|
||||
badge-tone="info"
|
||||
:title="`确认通过 ${request.id} 吗?`"
|
||||
:title="approveConfirmTitle"
|
||||
:description="approvalConfirmDescription"
|
||||
cancel-text="返回核对"
|
||||
confirm-text="确认通过"
|
||||
busy-text="通过中..."
|
||||
:confirm-text="approveConfirmText"
|
||||
:busy-text="approveBusyText"
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-check-circle-outline"
|
||||
:busy="approveBusy"
|
||||
@@ -771,7 +804,7 @@
|
||||
<strong>{{ request.node }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>下一节点</span>
|
||||
<span>{{ approvalConfirmSummaryLabel }}</span>
|
||||
<strong>{{ approvalNextStage }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
@@ -784,7 +817,7 @@
|
||||
<ReturnReasonDialog
|
||||
:open="returnDialogOpen"
|
||||
:title="`确认退回 ${request.id} 吗?`"
|
||||
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
|
||||
:description="returnDialogDescription"
|
||||
:busy="returnBusy"
|
||||
@close="closeReturnDialog"
|
||||
@confirm="confirmReturnRequest"
|
||||
|
||||
@@ -21,10 +21,13 @@ import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const ARCHIVE_TAB_ALL = '全部归档'
|
||||
const ARCHIVE_TAB_APPLICATION = '申请归档'
|
||||
const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'
|
||||
const ARCHIVE_TYPE_APPLICATION = '申请'
|
||||
const ARCHIVE_TYPE_APPLICATION_CODE = 'application'
|
||||
const ARCHIVE_TYPE_REIMBURSEMENT = '报销'
|
||||
const ARCHIVE_TYPE_REIMBURSEMENT_CODE = 'reimbursement'
|
||||
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT]
|
||||
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_APPLICATION, ARCHIVE_TAB_REIMBURSEMENT]
|
||||
const RISK_FILTER_OPTIONS = [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
|
||||
{ value: 'has', label: '有风险' },
|
||||
@@ -49,6 +52,7 @@ function buildArchiveRow(request) {
|
||||
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
|
||||
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
|
||||
const hasRisk = riskCount > 0
|
||||
const isApplicationDocument = normalized.documentTypeCode === 'application'
|
||||
const archiveMonth = extractArchiveMonth(
|
||||
normalized.updatedAt,
|
||||
normalized.submittedAt,
|
||||
@@ -68,16 +72,16 @@ function buildArchiveRow(request) {
|
||||
archivedAt: normalized.updatedAt || normalized.applyTime,
|
||||
archiveMonth,
|
||||
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
|
||||
archiveType: ARCHIVE_TYPE_REIMBURSEMENT,
|
||||
archiveTypeCode: ARCHIVE_TYPE_REIMBURSEMENT_CODE,
|
||||
node: normalized.workflowNode || '归档入账',
|
||||
archiveType: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT,
|
||||
archiveTypeCode: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION_CODE : ARCHIVE_TYPE_REIMBURSEMENT_CODE,
|
||||
node: isApplicationDocument ? '申请归档' : (normalized.workflowNode || '归档入账'),
|
||||
hasRisk,
|
||||
riskCount,
|
||||
risk: formatArchiveRiskCountLabel(riskCount),
|
||||
riskTone,
|
||||
status: '已归档',
|
||||
statusTone: 'archived',
|
||||
archiveTab: ARCHIVE_TAB_REIMBURSEMENT
|
||||
archiveTab: isApplicationDocument ? ARCHIVE_TAB_APPLICATION : ARCHIVE_TAB_REIMBURSEMENT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,7 @@ import {
|
||||
} from './auditViewModel.js'
|
||||
import {
|
||||
createDefaultRiskRuleForm,
|
||||
RISK_RULE_CREATE_DOMAIN_OPTIONS,
|
||||
RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
|
||||
RISK_RULE_LEVEL_OPTIONS
|
||||
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
|
||||
export default {
|
||||
@@ -141,6 +139,7 @@ export default {
|
||||
let spreadsheetOnlyOfficeHadLocalEdits = false
|
||||
let spreadsheetOnlyOfficeSyncSeq = 0
|
||||
let spreadsheetOnlyOfficeChangePollTimer = null
|
||||
const riskRuleGenerationPollTimers = new Map()
|
||||
const assetBuckets = ref({
|
||||
financialRules: [],
|
||||
riskRules: [],
|
||||
@@ -162,7 +161,7 @@ export default {
|
||||
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
|
||||
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
|
||||
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
|
||||
const showOnlineColumn = computed(() => activeType.value === 'riskRules')
|
||||
const showOnlineColumn = computed(() => false)
|
||||
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
|
||||
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
|
||||
const selectedSkillUsesSpreadsheet = computed(
|
||||
@@ -188,11 +187,19 @@ export default {
|
||||
const riskRuleInReview = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
|
||||
)
|
||||
const riskRuleGenerationBusy = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'generating'
|
||||
)
|
||||
const riskRuleGenerationFailed = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'failed'
|
||||
)
|
||||
const canOpenRiskRuleTest = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
canEditSelected.value &&
|
||||
Boolean(selectedSkill.value?.id) &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!riskRuleGenerationFailed.value &&
|
||||
!detailBusy.value
|
||||
)
|
||||
const canDeleteRiskRule = computed(
|
||||
@@ -203,11 +210,17 @@ export default {
|
||||
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
|
||||
!detailBusy.value
|
||||
)
|
||||
const canSubmitRiskRuleReview = computed(
|
||||
const canOpenRiskRuleReviewSubmit = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
canSubmitReview.value &&
|
||||
!riskRuleInReview.value &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!riskRuleGenerationFailed.value
|
||||
)
|
||||
const canSubmitRiskRuleReview = computed(
|
||||
() =>
|
||||
canOpenRiskRuleReviewSubmit.value &&
|
||||
riskRuleTestPassed.value
|
||||
)
|
||||
const canReturnRiskRule = computed(
|
||||
@@ -355,8 +368,8 @@ export default {
|
||||
const showRiskScenarioFilter = computed(() =>
|
||||
['financialRules', 'riskRules'].includes(activeType.value)
|
||||
)
|
||||
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
|
||||
const showOnlineFilter = computed(() => activeType.value === 'riskRules')
|
||||
const showStatusFilter = computed(() => true)
|
||||
const showOnlineFilter = computed(() => false)
|
||||
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
|
||||
const selectedRiskScenarioLabel = computed(
|
||||
() =>
|
||||
@@ -618,6 +631,11 @@ export default {
|
||||
return
|
||||
}
|
||||
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
|
||||
const ruleTitle = String(riskRuleCreateForm.value.rule_title || '').trim()
|
||||
if (ruleTitle.length < 2) {
|
||||
toast('请输入至少 2 个字的规则标题。')
|
||||
return
|
||||
}
|
||||
if (naturalLanguage.length < 8) {
|
||||
toast('请至少输入 8 个字的风险规则描述。')
|
||||
return
|
||||
@@ -627,11 +645,9 @@ export default {
|
||||
try {
|
||||
const detail = await generateRiskRuleAsset(
|
||||
{
|
||||
business_domain: riskRuleCreateForm.value.business_domain,
|
||||
expense_category: riskRuleCreateForm.value.business_domain === 'expense'
|
||||
? riskRuleCreateForm.value.expense_category
|
||||
: null,
|
||||
risk_level: riskRuleCreateForm.value.risk_level,
|
||||
business_domain: 'expense',
|
||||
expense_category: riskRuleCreateForm.value.expense_category,
|
||||
rule_title: ruleTitle,
|
||||
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
|
||||
natural_language: naturalLanguage
|
||||
},
|
||||
@@ -639,9 +655,8 @@ export default {
|
||||
)
|
||||
riskRuleCreateOpen.value = false
|
||||
await refreshCurrentAssets()
|
||||
selectedSkill.value = buildDetailViewModel(detail, runs.value)
|
||||
await loadRiskRuleJson(detail.id)
|
||||
toast('风险规则草稿已生成,请在详情中核对业务说明和判断流程。')
|
||||
scheduleRiskRuleGenerationPoll(detail.id)
|
||||
toast('风险规则已进入后台生成,列表会先显示生成中。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '风险规则生成失败,请稍后重试。')
|
||||
} finally {
|
||||
@@ -649,6 +664,40 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function stopRiskRuleGenerationPoll(assetId) {
|
||||
const timer = riskRuleGenerationPollTimers.get(assetId)
|
||||
if (timer) {
|
||||
window.clearTimeout(timer)
|
||||
riskRuleGenerationPollTimers.delete(assetId)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRiskRuleGenerationPoll(assetId, attempt = 0) {
|
||||
const normalizedAssetId = normalizeText(assetId)
|
||||
if (!normalizedAssetId) {
|
||||
return
|
||||
}
|
||||
stopRiskRuleGenerationPoll(normalizedAssetId)
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
await refreshCurrentAssets()
|
||||
const latest = (assetBuckets.value.riskRules || []).find((item) => item.id === normalizedAssetId)
|
||||
if (!latest || latest.statusValue !== 'generating' || attempt >= 59) {
|
||||
riskRuleGenerationPollTimers.delete(normalizedAssetId)
|
||||
return
|
||||
}
|
||||
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
|
||||
} catch {
|
||||
if (attempt < 59) {
|
||||
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
|
||||
} else {
|
||||
riskRuleGenerationPollTimers.delete(normalizedAssetId)
|
||||
}
|
||||
}
|
||||
}, attempt === 0 ? 1200 : 3000)
|
||||
riskRuleGenerationPollTimers.set(normalizedAssetId, timer)
|
||||
}
|
||||
|
||||
async function persistRuleRuntimeConfig(asset, runtimeRule) {
|
||||
await updateAgentAsset(
|
||||
asset.id,
|
||||
@@ -1033,6 +1082,9 @@ export default {
|
||||
loadSpreadsheetChangeRecords(assetId).catch(() => {})
|
||||
}
|
||||
if (selectedSkill.value.usesJsonRiskRule) {
|
||||
if (selectedSkill.value.riskRuleGenerationFailed || selectedSkill.value.riskRuleGenerationBusy) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await loadRiskRuleJson(assetId)
|
||||
} catch (jsonError) {
|
||||
@@ -1143,6 +1195,10 @@ export default {
|
||||
}
|
||||
|
||||
function openAssetDetail(asset) {
|
||||
if (asset?.usesJsonRiskRule && asset.statusValue === 'generating') {
|
||||
toast('规则仍在后台生成中,生成完成后才能进入详情。')
|
||||
return
|
||||
}
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
spreadsheetOnlyOfficeError.value = ''
|
||||
spreadsheetOnlyOfficeLoading.value = false
|
||||
@@ -1397,11 +1453,13 @@ export default {
|
||||
}
|
||||
|
||||
async function openSubmitReviewDialog() {
|
||||
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
|
||||
if (
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
!canOpenRiskRuleReviewSubmit.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
|
||||
toast('请先在“测试规则”中保存测试通过报告,再提交审核。')
|
||||
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
|
||||
return
|
||||
}
|
||||
reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || ''
|
||||
@@ -1698,6 +1756,8 @@ export default {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroySpreadsheetOnlyOfficeEditor()
|
||||
riskRuleGenerationPollTimers.forEach((timer) => window.clearTimeout(timer))
|
||||
riskRuleGenerationPollTimers.clear()
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
@@ -1753,6 +1813,7 @@ export default {
|
||||
canCreateRiskRule,
|
||||
canOpenRiskRuleTest,
|
||||
canDeleteRiskRule,
|
||||
canOpenRiskRuleReviewSubmit,
|
||||
canSubmitRiskRuleReview,
|
||||
canReturnRiskRule,
|
||||
canPublishRiskRule,
|
||||
@@ -1790,9 +1851,7 @@ export default {
|
||||
riskRuleReturnOpen,
|
||||
riskRulePublishOpen,
|
||||
riskRuleReturnNote,
|
||||
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
|
||||
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
|
||||
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
|
||||
showReviewNote,
|
||||
spreadsheetUploadInput,
|
||||
spreadsheetOnlyOfficeLoading,
|
||||
|
||||
216
web/src/views/scripts/BudgetCenterView.js
Normal file
216
web/src/views/scripts/BudgetCenterView.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||
import { fetchEmployeeMeta } from '../../services/employees.js'
|
||||
|
||||
const FALLBACK_DEPARTMENTS = [
|
||||
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
|
||||
{ code: 'FINANCE-DEPT', name: '财务部', costCenter: 'CC-2100' },
|
||||
{ code: 'TECH-DEPT', name: '技术部', costCenter: 'CC-6100' },
|
||||
{ code: 'HR-DEPT', name: '人力资源部', costCenter: 'CC-3200' },
|
||||
{ code: 'PRODUCTION-DEPT', name: '生产部', costCenter: 'CC-7200' },
|
||||
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
|
||||
]
|
||||
|
||||
const EXPENSE_BLUEPRINTS = [
|
||||
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
|
||||
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
|
||||
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
|
||||
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
|
||||
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
|
||||
]
|
||||
|
||||
const currency = (value) =>
|
||||
Number(value || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
|
||||
function buildDepartmentRows(departmentCode) {
|
||||
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
|
||||
const factor = 0.88 + (seed % 18) / 100
|
||||
|
||||
return EXPENSE_BLUEPRINTS.map((item, index) => {
|
||||
const totalAmount = Math.round(item.total * factor)
|
||||
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
|
||||
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
|
||||
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
|
||||
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
|
||||
|
||||
return {
|
||||
...item,
|
||||
totalAmount,
|
||||
usedAmount,
|
||||
occupiedAmount,
|
||||
leftAmount,
|
||||
rate,
|
||||
rateTone: rate >= item.warning ? 'danger' : rate >= item.warning - 12 ? 'warn' : 'ok',
|
||||
warningTone: item.warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
|
||||
warningLine: `${item.warning}%`,
|
||||
total: currency(totalAmount),
|
||||
used: currency(usedAmount),
|
||||
occupied: currency(occupiedAmount),
|
||||
left: currency(leftAmount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildTrendData(rows) {
|
||||
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
|
||||
const used = rows.reduce((sum, item) => sum + item.usedAmount + item.occupiedAmount, 0)
|
||||
|
||||
return {
|
||||
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
||||
budget: [0.05, 0.18, 0.25, 0.34, 0.45, 0.52, 0.68, 0.76, 0.84, 0.91, 0.96, 1].map((ratio) =>
|
||||
Math.round(total * ratio)
|
||||
),
|
||||
used: [0.03, 0.1, 0.13, 0.22, 0.3, 0.37, 0.51, 0.59, 0.69, 0.73, 0.86, 0.96].map((ratio) =>
|
||||
Math.round(used * ratio)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'BudgetCenterView',
|
||||
components: {
|
||||
BudgetTrendChart
|
||||
},
|
||||
setup() {
|
||||
const departments = ref(FALLBACK_DEPARTMENTS)
|
||||
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
||||
const departmentKeyword = ref('')
|
||||
const filters = ref({
|
||||
period: '2026年度',
|
||||
expenseType: '全部',
|
||||
status: '全部'
|
||||
})
|
||||
|
||||
const activeDepartment = computed(() =>
|
||||
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
|
||||
)
|
||||
|
||||
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
|
||||
const departmentRows = computed(() => buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value))
|
||||
const visibleBudgetRows = computed(() =>
|
||||
departmentRows.value
|
||||
.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 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)
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
occupied,
|
||||
left: Math.max(total - used - occupied, 0)
|
||||
}
|
||||
})
|
||||
|
||||
const budgetMetrics = computed(() => [
|
||||
{
|
||||
label: '预算总额',
|
||||
value: `¥${currency(totals.value.total)}`,
|
||||
note: '本年累计',
|
||||
tone: 'green',
|
||||
icon: 'mdi mdi-wallet-outline'
|
||||
},
|
||||
{
|
||||
label: '已发生',
|
||||
value: `¥${currency(totals.value.used)}`,
|
||||
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
|
||||
tone: 'blue',
|
||||
icon: 'mdi mdi-chart-line'
|
||||
},
|
||||
{
|
||||
label: '已占用',
|
||||
value: `¥${currency(totals.value.occupied)}`,
|
||||
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
|
||||
tone: 'orange',
|
||||
icon: 'mdi mdi-briefcase-check-outline'
|
||||
},
|
||||
{
|
||||
label: '剩余可用',
|
||||
value: `¥${currency(totals.value.left)}`,
|
||||
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
|
||||
tone: 'green',
|
||||
icon: 'mdi mdi-currency-cny'
|
||||
}
|
||||
])
|
||||
|
||||
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'
|
||||
}))
|
||||
})
|
||||
|
||||
const warnings = computed(() =>
|
||||
departmentRows.value
|
||||
.slice()
|
||||
.sort((a, b) => b.rate - a.rate)
|
||||
.slice(0, 4)
|
||||
.map((row, index) => ({
|
||||
title: row.expenseType,
|
||||
desc: `使用率已达 ${row.rate}%,${row.rate >= row.warning ? '已超过预警线' : '接近预警线'}(${row.warningLine})`,
|
||||
date: index < 2 ? '2026-05-12' : '2026-05-10',
|
||||
tone: row.rate >= row.warning ? 'danger' : row.rate >= row.warning - 12 ? 'warn' : 'ok'
|
||||
}))
|
||||
)
|
||||
|
||||
const trendData = computed(() => buildTrendData(departmentRows.value))
|
||||
|
||||
async function loadDepartments() {
|
||||
try {
|
||||
const payload = await fetchEmployeeMeta()
|
||||
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
|
||||
const nextDepartments = options
|
||||
.filter((item) => item?.code && item?.name)
|
||||
.map((item) => ({
|
||||
code: String(item.code),
|
||||
name: String(item.name),
|
||||
costCenter: String(item.costCenter || '')
|
||||
}))
|
||||
|
||||
if (nextDepartments.length) {
|
||||
departments.value = nextDepartments
|
||||
if (!nextDepartments.some((item) => item.code === activeDepartmentCode.value)) {
|
||||
activeDepartmentCode.value = nextDepartments[0].code
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load budget departments from employee meta:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDepartments()
|
||||
})
|
||||
|
||||
return {
|
||||
activeDepartmentCode,
|
||||
activeDepartmentName,
|
||||
budgetMetrics,
|
||||
departmentKeyword,
|
||||
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
||||
filters,
|
||||
periods: ['2026年度', '2026年Q2', '2026年5月'],
|
||||
statuses: ['全部', '正常', '预警', '管控'],
|
||||
trendData,
|
||||
visibleBudgetRows,
|
||||
visibleDepartments,
|
||||
warnings
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const FALLBACK_ROLE_OPTIONS = [
|
||||
id: 'approver',
|
||||
code: 'approver',
|
||||
label: '审批负责人',
|
||||
desc: '可以处理审批中心中的待审单据。'
|
||||
desc: '可以处理单据中心中的待审单据。'
|
||||
},
|
||||
{
|
||||
id: 'executive',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementRevi
|
||||
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
|
||||
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
|
||||
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
|
||||
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
@@ -34,6 +35,12 @@ import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||||
import {
|
||||
buildApplicationPreviewFooterMessage,
|
||||
buildApplicationPreviewSubmitText,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
calculateTravelReimbursement,
|
||||
fetchExpenseClaims,
|
||||
@@ -520,6 +527,10 @@ export default {
|
||||
invalidatedDraftClaimId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
reopenToken: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
emits: ['close', 'draft-saved'],
|
||||
@@ -578,7 +589,22 @@ export default {
|
||||
const reviewActionBusy = ref(false)
|
||||
const deleteSessionBusy = ref(false)
|
||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const {
|
||||
applicationPreviewEditor,
|
||||
resolveApplicationPreviewRows,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
} = useApplicationPreviewEditor({
|
||||
persistSessionState,
|
||||
toast
|
||||
})
|
||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
|
||||
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
|
||||
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
|
||||
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
|
||||
@@ -642,9 +668,9 @@ export default {
|
||||
)
|
||||
})
|
||||
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
|
||||
const hasInsightPanelContent = computed(
|
||||
() => isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
|
||||
)
|
||||
const hasInsightPanelContent = computed(() => {
|
||||
return isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
|
||||
})
|
||||
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
|
||||
const insightPanelToggleLabel = computed(() =>
|
||||
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
|
||||
@@ -820,7 +846,7 @@ export default {
|
||||
applyComposerDateSelection,
|
||||
resolveTravelCalculatorInitialDays,
|
||||
resolveTravelCalculatorInitialLocation,
|
||||
openTravelCalculator,
|
||||
openTravelCalculator: openTravelCalculatorInternal,
|
||||
toggleTravelCalculator: toggleTravelCalculatorInternal,
|
||||
closeTravelCalculator,
|
||||
formatTravelCalculatorMoney,
|
||||
@@ -845,6 +871,7 @@ export default {
|
||||
buildLocallySyncedReviewPayload,
|
||||
formatDateInputValue
|
||||
})
|
||||
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
|
||||
const {
|
||||
fileInputMode,
|
||||
attachedFiles,
|
||||
@@ -940,6 +967,7 @@ export default {
|
||||
fetchExpenseClaims,
|
||||
fileInputRef,
|
||||
flowRunId,
|
||||
insightPanelCollapsed,
|
||||
isKnowledgeSession,
|
||||
linkedRequest,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
@@ -1011,13 +1039,30 @@ export default {
|
||||
openTravelCalculator,
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer: submitComposerInternal,
|
||||
currentUser,
|
||||
toast
|
||||
})
|
||||
function openTravelCalculator() {
|
||||
if (!canShowTravelCalculator.value) {
|
||||
closeTravelCalculator()
|
||||
return false
|
||||
}
|
||||
return openTravelCalculatorInternal()
|
||||
}
|
||||
|
||||
function toggleTravelCalculator() {
|
||||
if (!canShowTravelCalculator.value) {
|
||||
closeTravelCalculator()
|
||||
return false
|
||||
}
|
||||
return toggleTravelCalculatorInternal()
|
||||
}
|
||||
|
||||
function submitTravelCalculator() {
|
||||
if (!canShowTravelCalculator.value) {
|
||||
closeTravelCalculator()
|
||||
return false
|
||||
}
|
||||
// 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。
|
||||
// calculateTravelReimbursement({ grade: String(user.grade || '').trim() })
|
||||
// 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计
|
||||
@@ -1027,6 +1072,11 @@ export default {
|
||||
// messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload)
|
||||
return submitTravelCalculatorInternal()
|
||||
}
|
||||
watch(canShowTravelCalculator, (visible) => {
|
||||
if (!visible && travelCalculatorOpen.value) {
|
||||
closeTravelCalculator()
|
||||
}
|
||||
})
|
||||
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
||||
|
||||
const shortcuts = computed(() =>
|
||||
@@ -1142,6 +1192,21 @@ export default {
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.reopenToken,
|
||||
(token, previousToken) => {
|
||||
if (token === previousToken) {
|
||||
return
|
||||
}
|
||||
closeAfterBusy.value = false
|
||||
workbenchVisible.value = true
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
|
||||
() => {
|
||||
@@ -1510,7 +1575,7 @@ export default {
|
||||
const claimNo = String(record.claimNo || '该单据').trim()
|
||||
const route = claimId
|
||||
? router.resolve({
|
||||
name: 'app-request-detail',
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
: null
|
||||
@@ -1558,7 +1623,7 @@ export default {
|
||||
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
|
||||
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
|
||||
const route = router.resolve({
|
||||
name: 'app-request-detail',
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
return {
|
||||
@@ -1579,6 +1644,9 @@ export default {
|
||||
}
|
||||
|
||||
function buildMessageBubbleClass(message) {
|
||||
if (message?.role === 'assistant' && message?.applicationPreview) {
|
||||
return 'message-bubble-application-preview'
|
||||
}
|
||||
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
|
||||
return ''
|
||||
}
|
||||
@@ -1635,10 +1703,27 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function buildApplicationPreviewFooterText(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return ''
|
||||
}
|
||||
return buildApplicationPreviewFooterMessage(message.applicationPreview)
|
||||
}
|
||||
|
||||
function openApplicationSubmitConfirm(message) {
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
if (message.applicationPreview) {
|
||||
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
|
||||
message.applicationPreview = normalizedPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
|
||||
if (!normalizedPreview.readyToSubmit) {
|
||||
toast(`请先补充:${normalizedPreview.missingFields.join('、')}。`)
|
||||
persistSessionState()
|
||||
return
|
||||
}
|
||||
}
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: true,
|
||||
message
|
||||
@@ -1660,6 +1745,12 @@ export default {
|
||||
if (!message || submitting.value || reviewActionBusy.value) {
|
||||
return
|
||||
}
|
||||
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
|
||||
? normalizeApplicationPreview(message.applicationPreview)
|
||||
: null
|
||||
const applicationSubmitText = applicationPreview
|
||||
? buildApplicationPreviewSubmitText(applicationPreview)
|
||||
: '确认提交'
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null
|
||||
@@ -1667,10 +1758,15 @@ export default {
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const payload = await submitComposer({
|
||||
rawText: '确认提交',
|
||||
rawText: applicationSubmitText,
|
||||
userText: '确认提交',
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true
|
||||
systemGenerated: true,
|
||||
skipScopeGuard: true,
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
user_input_text: applicationSubmitText
|
||||
}
|
||||
})
|
||||
const draftPayload = payload?.result?.draft_payload || {}
|
||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||
@@ -1708,6 +1804,9 @@ export default {
|
||||
}
|
||||
|
||||
function emitCloseAfterLeave() {
|
||||
if (workbenchVisible.value) {
|
||||
return
|
||||
}
|
||||
if (closeAfterBusy.value && isWorkbenchBusy()) {
|
||||
return
|
||||
}
|
||||
@@ -1722,7 +1821,7 @@ export default {
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: 'app-request-detail',
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
emit('close')
|
||||
@@ -2018,12 +2117,12 @@ export default {
|
||||
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
||||
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
|
||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, applicationSubmitConfirmDialog, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, canShowTravelCalculator, deleteSessionDialogOpen, applicationSubmitConfirmDialog, applicationPreviewEditor, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@ import {
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
canApproveLeaderExpenseClaims,
|
||||
canDeleteArchivedExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
canReturnExpenseClaims,
|
||||
isFinanceUser
|
||||
} from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import { buildLeaderApprovalInfo, resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js'
|
||||
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
|
||||
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
@@ -460,7 +463,13 @@ export default {
|
||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
|
||||
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
||||
const canDeleteRequest = computed(() => {
|
||||
if (isArchivedRequest.value) {
|
||||
return canDeleteArchivedExpenseClaims(currentUser.value)
|
||||
}
|
||||
return isEditableRequest.value || canManageCurrentClaim.value
|
||||
})
|
||||
const isDirectManagerApprovalStage = computed(() => {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '直属领导审批'
|
||||
@@ -475,7 +484,7 @@ export default {
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canApproveRequest = computed(() =>
|
||||
Boolean(props.approvalMode)
|
||||
(Boolean(props.approvalMode) || isApplicationDocument.value)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
&& (
|
||||
@@ -490,7 +499,37 @@ export default {
|
||||
)
|
||||
)
|
||||
)
|
||||
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
|
||||
const showApplicationLeaderOpinionInput = computed(() => (
|
||||
isApplicationDocument.value
|
||||
&& canApproveRequest.value
|
||||
&& isDirectManagerApprovalStage.value
|
||||
))
|
||||
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
||||
const leaderApprovalReadonlyText = computed(() => {
|
||||
if (leaderApprovalInfo.value.opinion) {
|
||||
return leaderApprovalInfo.value.opinion
|
||||
}
|
||||
return isApplicationDocument.value ? '待直属领导填写审批意见。' : ''
|
||||
})
|
||||
const leaderApprovalReadonlyMeta = computed(() => {
|
||||
const pieces = [
|
||||
leaderApprovalInfo.value.operator ? `${leaderApprovalInfo.value.operator}确认` : '',
|
||||
leaderApprovalInfo.value.time
|
||||
].filter(Boolean)
|
||||
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
|
||||
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
|
||||
}
|
||||
return pieces.join(' · ')
|
||||
})
|
||||
const showApplicationLeaderOpinion = computed(() => (
|
||||
isApplicationDocument.value
|
||||
&& (
|
||||
showApplicationLeaderOpinionInput.value
|
||||
|| leaderApprovalReadonlyText.value
|
||||
)
|
||||
))
|
||||
const showLeaderApprovalPanel = computed(() => canApproveRequest.value && !showApplicationLeaderOpinionInput.value)
|
||||
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
|
||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||||
const approvalOpinionPlaceholder = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
@@ -505,7 +544,7 @@ export default {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '审核通过后将进入归档入账。'
|
||||
}
|
||||
return isApplicationDocument.value ? '审批通过后申请流程完成。' : '审批通过后将流转至财务审批。'
|
||||
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。'
|
||||
})
|
||||
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
||||
const approvalConfirmDescription = computed(() => {
|
||||
@@ -513,7 +552,7 @@ export default {
|
||||
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||||
}
|
||||
if (isApplicationDocument.value) {
|
||||
return '确认后该申请单会完成直属领导审批,请确认申请信息与领导意见无误。'
|
||||
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。'
|
||||
}
|
||||
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||
})
|
||||
@@ -521,14 +560,29 @@ export default {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '归档入账'
|
||||
}
|
||||
return isApplicationDocument.value ? '审批完成' : '财务审批'
|
||||
return isApplicationDocument.value ? '报销草稿' : '财务审批'
|
||||
})
|
||||
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
|
||||
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
|
||||
const approveConfirmTitle = computed(() => (
|
||||
isApplicationDocument.value ? `确认审核 ${request.value.id} 吗?` : `确认通过 ${request.value.id} 吗?`
|
||||
))
|
||||
const approveConfirmText = computed(() => (isApplicationDocument.value ? '确认审核' : '确认通过'))
|
||||
const approveBusyText = computed(() => (isApplicationDocument.value ? '确认中...' : '通过中...'))
|
||||
const returnDialogDescription = computed(() => (
|
||||
isApplicationDocument.value
|
||||
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
|
||||
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
|
||||
))
|
||||
const approvalConfirmSummaryLabel = computed(() => (
|
||||
isApplicationDocument.value ? '生成结果' : '下一节点'
|
||||
))
|
||||
const approvalSuccessToast = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return `${request.value.id} 已完成财务终审,进入归档入账。`
|
||||
}
|
||||
return isApplicationDocument.value
|
||||
? `${request.value.id} 申请已审批通过。`
|
||||
? `${request.value.id} 已确认审核,正在生成报销草稿。`
|
||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||
})
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
@@ -613,13 +667,6 @@ export default {
|
||||
value: request.value.typeLabel,
|
||||
icon: '',
|
||||
valueClass: ''
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '当前状态',
|
||||
value: request.value.node,
|
||||
icon: '',
|
||||
valueClass: 'status'
|
||||
}
|
||||
])
|
||||
|
||||
@@ -652,6 +699,7 @@ export default {
|
||||
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
|
||||
return formatCurrency(total)
|
||||
})
|
||||
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const expenseTableColumnCount = computed(
|
||||
@@ -1582,7 +1630,11 @@ export default {
|
||||
}
|
||||
|
||||
if (!canDeleteRequest.value) {
|
||||
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
|
||||
toast(
|
||||
isArchivedRequest.value
|
||||
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
||||
: '当前单据已进入流程,只有高级管理人员可以删除。'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1668,6 +1720,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
||||
toast('请先填写领导意见,填写后才能确认审核。')
|
||||
return
|
||||
}
|
||||
|
||||
approveConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1692,14 +1749,25 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
||||
toast('请先填写领导意见,填写后才能确认审核。')
|
||||
approveConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
approveBusy.value = true
|
||||
try {
|
||||
await approveExpenseClaim(request.value.claimId, {
|
||||
const responsePayload = await approveExpenseClaim(request.value.claimId, {
|
||||
opinion: leaderOpinion.value.trim()
|
||||
})
|
||||
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
|
||||
approveConfirmDialogOpen.value = false
|
||||
leaderOpinion.value = ''
|
||||
toast(approvalSuccessToast.value)
|
||||
toast(
|
||||
isApplicationDocument.value && generatedDraftClaimNo
|
||||
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
|
||||
: approvalSuccessToast.value
|
||||
)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '审批通过失败,请稍后重试。')
|
||||
@@ -1736,8 +1804,11 @@ export default {
|
||||
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
|
||||
approvalOpinionTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview,
|
||||
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint,
|
||||
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
||||
applicationDetailFactItems,
|
||||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog,
|
||||
closeRiskOverrideDialog,
|
||||
@@ -1756,12 +1827,15 @@ export default {
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
leaderApprovalReadonlyMeta, leaderApprovalReadonlyText,
|
||||
resolveExpenseRiskIndicatorTitle,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
requiresApprovalOpinion,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
showAiAdvicePanel, showApplicationLeaderOpinion, showApplicationLeaderOpinionInput,
|
||||
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ export const RULE_TABLE_COLUMNS = {
|
||||
export const RISK_RULE_TABLE_COLUMNS = {
|
||||
...RULE_TABLE_COLUMNS,
|
||||
owner: '审核人',
|
||||
metric: '发布者',
|
||||
updatedAt: '发布时间'
|
||||
status: '状态',
|
||||
metric: '创建者',
|
||||
updatedAt: '创建时间'
|
||||
}
|
||||
|
||||
export const TYPE_META = {
|
||||
@@ -100,7 +101,7 @@ export const TAB_META = {
|
||||
tableColumns: RISK_RULE_TABLE_COLUMNS,
|
||||
showRuntimeColumn: false,
|
||||
showVersionColumn: false,
|
||||
showStatusColumn: false,
|
||||
showStatusColumn: true,
|
||||
badgeTone: 'rose'
|
||||
},
|
||||
skills: {
|
||||
@@ -121,10 +122,12 @@ export const TAB_META = {
|
||||
}
|
||||
|
||||
export const STATUS_META = {
|
||||
generating: { label: '生成中', tone: 'info' },
|
||||
draft: { label: '草稿中', tone: 'draft' },
|
||||
review: { label: '待审核', tone: 'warning' },
|
||||
active: { label: '已上线', tone: 'success' },
|
||||
disabled: { label: '已停用', tone: 'disabled' }
|
||||
disabled: { label: '已停用', tone: 'disabled' },
|
||||
failed: { label: '生成失败', tone: 'danger' }
|
||||
}
|
||||
|
||||
export const REVIEW_META = {
|
||||
@@ -250,10 +253,12 @@ export const DETAIL_TITLES = {
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'generating', label: '生成中' },
|
||||
{ value: 'draft', label: '草稿中' },
|
||||
{ value: 'review', label: '待审核' },
|
||||
{ value: 'active', label: '已上线' },
|
||||
{ value: 'disabled', label: '已停用' }
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
{ value: 'failed', label: '生成失败' }
|
||||
]
|
||||
|
||||
export const ONLINE_STATE_OPTIONS = [
|
||||
@@ -285,6 +290,15 @@ export const RULE_TAB_TAG_ALIASES = {
|
||||
|
||||
export const RISK_SCENARIO_OPTIONS = [
|
||||
{ value: '', label: '全部场景' },
|
||||
{ value: '差旅费', label: '差旅费' },
|
||||
{ value: '住宿费', label: '住宿费' },
|
||||
{ value: '交通费', label: '交通费' },
|
||||
{ value: '业务招待费', label: '业务招待费' },
|
||||
{ value: '会务费', label: '会务费' },
|
||||
{ value: '办公用品费', label: '办公用品费' },
|
||||
{ value: '培训费', label: '培训费' },
|
||||
{ value: '通讯费', label: '通讯费' },
|
||||
{ value: '福利费', label: '福利费' },
|
||||
{ value: '差旅', label: '差旅' },
|
||||
{ value: '发票', label: '发票' },
|
||||
{ value: '餐饮招待', label: '餐饮招待' },
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
resolveRiskRuleFields,
|
||||
resolveRiskRuleFlow,
|
||||
resolveRiskRuleFlowDiagramSvg,
|
||||
resolveRiskRuleScore,
|
||||
resolveRiskRuleScoreDetail,
|
||||
resolveRiskRuleScoreLabel,
|
||||
resolveRiskRuleScoreLevel,
|
||||
resolveRiskRuleSeverity,
|
||||
resolveRiskRuleSeverityLabel
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
@@ -327,6 +331,14 @@ export function readScenarioItems(source) {
|
||||
|
||||
export function resolveRiskRuleCategory(source) {
|
||||
const configJson = readConfigJson(source)
|
||||
const expenseCategoryLabel =
|
||||
normalizeText(configJson.expense_category_label) ||
|
||||
normalizeText(configJson.metadata?.expense_category_label) ||
|
||||
normalizeText(source?.expense_category_label)
|
||||
if (expenseCategoryLabel) {
|
||||
return expenseCategoryLabel
|
||||
}
|
||||
|
||||
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
|
||||
if (explicit) {
|
||||
return explicit
|
||||
@@ -442,16 +454,24 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
||||
normalizeText(apiPayload?.description) ||
|
||||
normalizeText(target.riskRuleDescription)
|
||||
const riskCategory =
|
||||
normalizeText(metadata.expense_category_label) ||
|
||||
normalizeText(apiConfig.expense_category_label) ||
|
||||
normalizeText(rulePayload.risk_category) ||
|
||||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
|
||||
const riskRuleFields = resolveRiskRuleFields(rulePayload)
|
||||
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
|
||||
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
|
||||
|
||||
const statusValue = apiPayload?.status || target.statusValue || 'draft'
|
||||
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
|
||||
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
|
||||
|
||||
const publisher = apiPayload?.created_by || target.publisher || (apiPayload?.recent_versions && apiPayload.recent_versions[0]?.created_by) || '系统管理员'
|
||||
const publisher =
|
||||
target.creator ||
|
||||
normalizeText(apiPayload?.owner) ||
|
||||
normalizeText(metadata.created_by) ||
|
||||
normalizeText(apiPayload?.recent_versions?.[0]?.created_by) ||
|
||||
'未知'
|
||||
|
||||
let publishedAt = target.publishedAt || '-'
|
||||
if (apiPayload?.recent_versions) {
|
||||
@@ -470,15 +490,23 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
||||
riskCategory,
|
||||
scope: riskCategory,
|
||||
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
|
||||
riskRuleSeverity: resolveRiskRuleSeverity(rulePayload),
|
||||
riskRuleSeverityLabel: resolveRiskRuleSeverityLabel(rulePayload),
|
||||
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
|
||||
riskRuleSeverityLabel: riskRuleScoreLevel
|
||||
? resolveRiskRuleScoreLabel(rulePayload, apiConfig)
|
||||
: resolveRiskRuleSeverityLabel(rulePayload),
|
||||
riskRuleScore: resolveRiskRuleScore(rulePayload, apiConfig),
|
||||
riskRuleScoreLabel: resolveRiskRuleScoreLabel(rulePayload, apiConfig),
|
||||
riskRuleScoreLevel: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
|
||||
riskRuleScoreDetail: resolveRiskRuleScoreDetail(rulePayload, apiConfig),
|
||||
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
|
||||
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
|
||||
riskRuleFields,
|
||||
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
|
||||
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
|
||||
riskRuleFlowDiagramSvg:
|
||||
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
|
||||
riskRuleFlowDiagramSvg: resolveRiskRuleFlowDiagramSvg({
|
||||
...rulePayload,
|
||||
flow_diagram_svg: normalizeText(apiPayload?.flow_diagram_svg) || rulePayload?.flow_diagram_svg
|
||||
}),
|
||||
riskRuleRequiresAttachment: Boolean(
|
||||
rulePayload.requires_attachment ||
|
||||
metadata.requires_attachment ||
|
||||
@@ -860,12 +888,13 @@ export function buildListItem(asset) {
|
||||
const isOnlineValue = asset.status === 'active'
|
||||
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
|
||||
const reviewer = normalizeText(asset.reviewer) || '待分配'
|
||||
const publisher = isRiskRule
|
||||
? isOnlineValue
|
||||
? normalizeText(asset.published_by) || reviewer || modifiedBy || '系统管理员'
|
||||
: '-'
|
||||
: ''
|
||||
const publishedAt = isRiskRule && isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-'
|
||||
const creator =
|
||||
normalizeText(asset.owner) ||
|
||||
normalizeText(asset.config_json?.generation_request?.actor) ||
|
||||
modifiedBy ||
|
||||
'未知'
|
||||
const publisher = isRiskRule ? creator : ''
|
||||
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
@@ -895,8 +924,9 @@ export function buildListItem(asset) {
|
||||
statusValue: asset.status,
|
||||
statusTone: statusMeta.tone,
|
||||
hitRate: isRiskRule ? publisher : buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
|
||||
creator,
|
||||
publisher,
|
||||
publishedAt,
|
||||
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
|
||||
isOnlineValue,
|
||||
isOnlineLabel: isOnlineValue ? '是' : '否',
|
||||
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
|
||||
@@ -905,7 +935,7 @@ export function buildListItem(asset) {
|
||||
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
||||
modifiedBy,
|
||||
changeCount,
|
||||
updatedAt: isRiskRule ? publishedAt : formatDateTime(asset.updated_at),
|
||||
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
|
||||
badgeTone: tabMeta.badgeTone,
|
||||
domainValue: asset.domain
|
||||
}
|
||||
@@ -1283,6 +1313,13 @@ export function buildDetailViewModel(detail, runs) {
|
||||
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
|
||||
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
|
||||
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(detail) : true
|
||||
const generationStatus = normalizeText(configJson.generation_status || detail.status)
|
||||
const riskRuleGenerationFailed = usesJsonRiskRule && (detail.status === 'failed' || generationStatus === 'failed')
|
||||
const riskRuleGenerationBusy = usesJsonRiskRule && (detail.status === 'generating' || generationStatus === 'generating')
|
||||
const riskRuleCreator =
|
||||
normalizeText(detail.owner) ||
|
||||
normalizeText(detail.recent_versions?.[0]?.created_by) ||
|
||||
'未知'
|
||||
|
||||
return {
|
||||
id: detail.id,
|
||||
@@ -1321,6 +1358,10 @@ export function buildDetailViewModel(detail, runs) {
|
||||
riskRuleSourceRef: '',
|
||||
riskRuleSeverity: 'medium',
|
||||
riskRuleSeverityLabel: '中风险',
|
||||
riskRuleScore: null,
|
||||
riskRuleScoreLabel: '待计算',
|
||||
riskRuleScoreLevel: 'medium',
|
||||
riskRuleScoreDetail: null,
|
||||
riskRuleCreatedAt: formatDateTime(detail.created_at),
|
||||
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
|
||||
isOnlineLabel: detail.status === 'active' ? '是' : '否',
|
||||
@@ -1334,7 +1375,8 @@ export function buildDetailViewModel(detail, runs) {
|
||||
detail.reviewer ||
|
||||
(detail.recent_versions && detail.recent_versions[0]?.created_by) ||
|
||||
'系统管理员'
|
||||
: '-',
|
||||
: riskRuleCreator,
|
||||
creator: riskRuleCreator,
|
||||
publishedAt:
|
||||
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
|
||||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
|
||||
@@ -1344,6 +1386,10 @@ export function buildDetailViewModel(detail, runs) {
|
||||
riskRuleFlow: resolveRiskRuleFlow({}, []),
|
||||
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
|
||||
riskRuleRequiresAttachment: Boolean(configJson.requires_attachment),
|
||||
riskRuleGenerationStatus: generationStatus,
|
||||
riskRuleGenerationFailed,
|
||||
riskRuleGenerationBusy,
|
||||
riskRuleGenerationError: normalizeText(configJson.generation_error),
|
||||
latestTestSummary: detail.latest_test_summary || detail.latestTestSummary || null,
|
||||
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
|
||||
ruleDocument,
|
||||
|
||||
@@ -17,22 +17,40 @@ export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
|
||||
]
|
||||
|
||||
export const RISK_RULE_LEVEL_OPTIONS = [
|
||||
{ value: 'low', label: '低风险' },
|
||||
{ value: 'medium', label: '中风险' },
|
||||
{ value: 'high', label: '高风险' },
|
||||
{ value: 'low', label: '低风险' }
|
||||
{ value: 'critical', label: '极高风险' }
|
||||
]
|
||||
|
||||
const RISK_LEVEL_LABELS = {
|
||||
low: '低风险',
|
||||
medium: '中风险',
|
||||
high: '高风险'
|
||||
high: '高风险',
|
||||
critical: '极高风险'
|
||||
}
|
||||
|
||||
const RISK_SCORE_LEVEL_LABELS = RISK_LEVEL_LABELS
|
||||
|
||||
const CITY_ROUTE_CONDITION_SUMMARY =
|
||||
'判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明,或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。'
|
||||
|
||||
const CITY_ROUTE_FLOW_DECISION =
|
||||
'附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市'
|
||||
|
||||
const CITY_ROUTE_FLOW_EVIDENCE =
|
||||
'读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由'
|
||||
|
||||
const CITY_ROUTE_SEMANTIC_TYPES = new Set([
|
||||
'travel_city_consistency',
|
||||
'travel_route_city_consistency'
|
||||
])
|
||||
|
||||
export function createDefaultRiskRuleForm() {
|
||||
return {
|
||||
business_domain: 'expense',
|
||||
expense_category: 'travel',
|
||||
risk_level: 'medium',
|
||||
rule_title: '',
|
||||
requires_attachment: false,
|
||||
natural_language: ''
|
||||
}
|
||||
@@ -52,16 +70,66 @@ export function formatRiskRuleFieldDisplay(field) {
|
||||
}
|
||||
|
||||
export function resolveRiskRuleSeverity(payload) {
|
||||
const scoreLevel = resolveRiskRuleScoreLevel(payload)
|
||||
if (scoreLevel) {
|
||||
return scoreLevel
|
||||
}
|
||||
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
|
||||
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
|
||||
const severity = normalizeRiskRuleText(fail.severity || payload?.severity).toLowerCase()
|
||||
return ['low', 'medium', 'high'].includes(severity) ? severity : 'medium'
|
||||
return Object.prototype.hasOwnProperty.call(RISK_LEVEL_LABELS, severity) ? severity : 'medium'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleSeverityLabel(payload) {
|
||||
return RISK_LEVEL_LABELS[resolveRiskRuleSeverity(payload)] || '中风险'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleScore(payload, fallbackConfig = {}) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
|
||||
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
|
||||
const candidates = [
|
||||
metadata.risk_score,
|
||||
payload?.risk_score,
|
||||
fail.risk_score,
|
||||
fallbackConfig?.risk_score,
|
||||
fallbackConfig?.riskScore
|
||||
]
|
||||
for (const value of candidates) {
|
||||
const score = Number(value)
|
||||
if (Number.isFinite(score)) {
|
||||
return Math.max(0, Math.min(100, Math.round(score)))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveRiskRuleScoreLevel(payload, fallbackConfig = {}) {
|
||||
const score = resolveRiskRuleScore(payload, fallbackConfig)
|
||||
if (score === null) {
|
||||
return ''
|
||||
}
|
||||
if (score <= 30) return 'low'
|
||||
if (score <= 60) return 'medium'
|
||||
if (score <= 80) return 'high'
|
||||
return 'critical'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleScoreLabel(payload, fallbackConfig = {}) {
|
||||
const level = resolveRiskRuleScoreLevel(payload, fallbackConfig)
|
||||
return level ? RISK_SCORE_LEVEL_LABELS[level] : '待计算'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleScoreDetail(payload, fallbackConfig = {}) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const detail =
|
||||
metadata.risk_score_detail ||
|
||||
payload?.risk_score_detail ||
|
||||
fallbackConfig?.risk_score_detail ||
|
||||
fallbackConfig?.riskScoreDetail
|
||||
return detail && typeof detail === 'object' ? detail : null
|
||||
}
|
||||
|
||||
export function resolveRiskRuleFields(payload) {
|
||||
const inputs = payload && typeof payload === 'object' ? payload.inputs || {} : {}
|
||||
const fieldRows = Array.isArray(inputs.fields) ? inputs.fields : []
|
||||
@@ -146,15 +214,24 @@ export function resolveRiskRuleBusinessDescription(payload, fallback) {
|
||||
|
||||
export function resolveRiskRuleFlowDiagramSvg(payload) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
return (
|
||||
const svg =
|
||||
normalizeRiskRuleText(payload?.flow_diagram_svg) ||
|
||||
normalizeRiskRuleText(metadata.flow_diagram_svg)
|
||||
)
|
||||
if (svg && !svg.includes('data-risk-flow-detail="logic-v2"')) {
|
||||
return ''
|
||||
}
|
||||
if (isCityRouteConsistencyPayload(payload) && svg.includes('风险关键词')) {
|
||||
return ''
|
||||
}
|
||||
return svg
|
||||
}
|
||||
|
||||
export function resolveRiskRuleConditionSummary(payload) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const params = payload && typeof payload === 'object' ? payload.params || {} : {}
|
||||
if (isCityRouteConsistencyPayload(payload)) {
|
||||
return CITY_ROUTE_CONDITION_SUMMARY
|
||||
}
|
||||
return (
|
||||
normalizeRiskRuleText(metadata.condition_summary) ||
|
||||
normalizeRiskRuleText(params.condition_summary) ||
|
||||
@@ -168,13 +245,220 @@ export function resolveRiskRuleFlow(payload, fields) {
|
||||
const fieldSummary = buildRiskRuleFieldSummary(fields)
|
||||
const conditionSummary = resolveRiskRuleConditionSummary(payload)
|
||||
const severityLabel = resolveRiskRuleSeverityLabel(payload)
|
||||
const isCityRouteRule = isCityRouteConsistencyPayload(payload)
|
||||
|
||||
return {
|
||||
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
|
||||
evidence: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
|
||||
decision: normalizeRiskRuleText(flow.decision) || conditionSummary,
|
||||
evidence: isCityRouteRule
|
||||
? CITY_ROUTE_FLOW_EVIDENCE
|
||||
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
|
||||
decision: isCityRouteRule
|
||||
? CITY_ROUTE_FLOW_DECISION
|
||||
: normalizeRiskRuleText(flow.decision) || conditionSummary,
|
||||
basis: conditionSummary,
|
||||
...resolveRiskRuleFlowDetails(payload, fields),
|
||||
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
|
||||
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskRuleFlowDetails(payload, fields) {
|
||||
const params = payload && typeof payload === 'object' && payload.params && typeof payload.params === 'object'
|
||||
? payload.params
|
||||
: {}
|
||||
const ruleIr = params.rule_ir && typeof params.rule_ir === 'object' ? params.rule_ir : {}
|
||||
const facts = Array.isArray(ruleIr.facts) ? buildFactLines(ruleIr.facts, fields) : buildFieldFactLines(fields)
|
||||
const conditions = buildConditionLines(params, fields)
|
||||
const hitLogic = formatHitLogic(params.hit_logic) || normalizeRiskRuleText(params.formula)
|
||||
return {
|
||||
facts,
|
||||
conditions,
|
||||
hitLogic
|
||||
}
|
||||
}
|
||||
|
||||
function buildFactLines(facts, fields) {
|
||||
const labelByKey = buildLabelByKey(fields)
|
||||
const rows = facts
|
||||
.slice(0, 4)
|
||||
.map((fact) => {
|
||||
const id = normalizeRiskRuleText(fact?.id)
|
||||
const label = normalizeRiskRuleText(fact?.label || id || '事实')
|
||||
const fieldKeys = readStringList(fact?.fields)
|
||||
const fieldText = fieldKeys.slice(0, 3).map((key) => labelByKey[key] || key).join('∪')
|
||||
return `${id ? `${id}=` : ''}${label}: ${fieldText || '规则字段'}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
return rows.length ? rows : buildFieldFactLines(fields)
|
||||
}
|
||||
|
||||
function buildFieldFactLines(fields) {
|
||||
return (Array.isArray(fields) ? fields : [])
|
||||
.slice(0, 4)
|
||||
.map((field, index) => `${String.fromCharCode(65 + index)}=${formatRiskRuleFieldDisplay(field)}`)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function buildConditionLines(params, fields) {
|
||||
const labelByKey = buildLabelByKey(fields)
|
||||
const conditions = Array.isArray(params.conditions) ? params.conditions : []
|
||||
const rows = conditions
|
||||
.slice(0, 4)
|
||||
.map((condition, index) => formatConditionLine(condition, labelByKey, index + 1))
|
||||
.filter(Boolean)
|
||||
if (rows.length) {
|
||||
return rows
|
||||
}
|
||||
return normalizeRiskRuleText(params.condition_summary) ? [normalizeRiskRuleText(params.condition_summary)] : []
|
||||
}
|
||||
|
||||
function formatConditionLine(condition, labelByKey, index) {
|
||||
const operator = normalizeRiskRuleText(condition?.operator)
|
||||
const id = normalizeRiskRuleText(condition?.id || `C${index}`)
|
||||
const prefix = `${id}: `
|
||||
if (['not_in_scope', 'not_in_set', 'not_overlap'].includes(operator)) {
|
||||
return `${prefix}${formatFieldGroup(condition?.left_fields, labelByKey)} ∩ ${formatFieldGroup(condition?.right_fields, labelByKey)} = ∅`
|
||||
}
|
||||
if (['in_scope', 'overlap'].includes(operator)) {
|
||||
return `${prefix}${formatFieldGroup(condition?.left_fields, labelByKey)} ∩ ${formatFieldGroup(condition?.right_fields, labelByKey)} ≠ ∅`
|
||||
}
|
||||
if (operator === 'date_outside_range') {
|
||||
return `${prefix}${formatFieldGroup(condition?.date_fields, labelByKey)} 不在 [${formatFieldGroup(condition?.range_start_fields, labelByKey)}, ${formatFieldGroup(condition?.range_end_fields, labelByKey)}]`
|
||||
}
|
||||
if (['contains_any', 'not_contains_any'].includes(operator)) {
|
||||
const verb = operator === 'not_contains_any' ? '不含' : '包含'
|
||||
const keywords = readStringList(condition?.keywords).slice(0, 4).join('、') || '关键词'
|
||||
return `${prefix}${formatFieldGroup(condition?.fields, labelByKey)} ${verb} ${keywords}`
|
||||
}
|
||||
if (['exists_any', 'exists_all', 'all_present'].includes(operator)) {
|
||||
const verb = operator === 'exists_any' ? '任一有值' : '全部有值'
|
||||
return `${prefix}${formatFieldGroup(condition?.fields, labelByKey)} ${verb}`
|
||||
}
|
||||
const left = normalizeRiskRuleText(condition?.left)
|
||||
const right = normalizeRiskRuleText(condition?.right)
|
||||
if (left || right) {
|
||||
return `${prefix}${labelByKey[left] || left} ${operator || 'compare'} ${labelByKey[right] || right}`
|
||||
}
|
||||
return `${prefix}${operator || '规则条件'}`
|
||||
}
|
||||
|
||||
function formatFieldGroup(value, labelByKey) {
|
||||
const keys = readStringList(value)
|
||||
if (!keys.length) {
|
||||
return '字段集合'
|
||||
}
|
||||
return keys.slice(0, 3).map((key) => labelByKey[key] || key).join('∪')
|
||||
}
|
||||
|
||||
function formatHitLogic(value) {
|
||||
if (typeof value === 'string') {
|
||||
return normalizeRiskRuleText(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(formatHitLogic).filter(Boolean).join(' AND ')
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return ''
|
||||
}
|
||||
if (Array.isArray(value.all)) {
|
||||
return value.all.map(wrapLogicPart).filter(Boolean).join(' AND ')
|
||||
}
|
||||
if (Array.isArray(value.any)) {
|
||||
return value.any.map(wrapLogicPart).filter(Boolean).join(' OR ')
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'not')) {
|
||||
const text = wrapLogicPart(value.not)
|
||||
return text ? `NOT ${text}` : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function wrapLogicPart(value) {
|
||||
const text = formatHitLogic(value)
|
||||
return value && typeof value === 'object' && !Array.isArray(value) && text ? `(${text})` : text
|
||||
}
|
||||
|
||||
function buildLabelByKey(fields) {
|
||||
const map = {}
|
||||
;(Array.isArray(fields) ? fields : []).forEach((field) => {
|
||||
const key = normalizeRiskRuleText(field?.key)
|
||||
if (key) {
|
||||
map[key] = normalizeRiskRuleText(field?.label || key)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
function readStringList(value) {
|
||||
return Array.isArray(value) ? value.map(normalizeRiskRuleText).filter(Boolean) : []
|
||||
}
|
||||
|
||||
function isCityRouteConsistencyPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return false
|
||||
}
|
||||
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
|
||||
const params = payload.params && typeof payload.params === 'object' ? payload.params : {}
|
||||
const semanticType = normalizeRiskRuleText(payload.semantic_type || params.semantic_type)
|
||||
if (CITY_ROUTE_SEMANTIC_TYPES.has(semanticType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const text = [
|
||||
metadata.natural_language,
|
||||
params.natural_language,
|
||||
payload.description,
|
||||
metadata.condition_summary,
|
||||
params.condition_summary
|
||||
]
|
||||
.map(normalizeRiskRuleText)
|
||||
.join('\n')
|
||||
if (looksLikeCityRouteRuleText(text)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fieldKeys = new Set(resolveRiskRuleFieldKeys(payload))
|
||||
const hasAttachmentCity =
|
||||
fieldKeys.has('attachment.route_cities') || fieldKeys.has('attachment.hotel_city')
|
||||
const hasReferenceCity = fieldKeys.has('claim.location') || fieldKeys.has('item.item_location')
|
||||
return hasAttachmentCity && hasReferenceCity && text.includes('风险关键词')
|
||||
}
|
||||
|
||||
function resolveRiskRuleFieldKeys(payload) {
|
||||
const keys = []
|
||||
const inputs = payload.inputs && typeof payload.inputs === 'object' ? payload.inputs : {}
|
||||
if (Array.isArray(inputs.fields)) {
|
||||
inputs.fields.forEach((item) => {
|
||||
const key = normalizeRiskRuleText(item?.key)
|
||||
if (key) keys.push(key)
|
||||
})
|
||||
}
|
||||
;[payload.field_keys, payload.params?.field_keys, payload.params?.search_fields].forEach((rows) => {
|
||||
if (!Array.isArray(rows)) return
|
||||
rows.forEach((item) => {
|
||||
const key = normalizeRiskRuleText(item)
|
||||
if (key) keys.push(key)
|
||||
})
|
||||
})
|
||||
return [...new Set(keys)]
|
||||
}
|
||||
|
||||
function looksLikeCityRouteRuleText(text) {
|
||||
const normalized = normalizeRiskRuleText(text)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
const hasCitySubject = ['交通票', '住宿票', '住宿发票', '票据', '附件', '行程城市', '住宿城市'].some(
|
||||
(term) => normalized.includes(term)
|
||||
)
|
||||
const hasReference = ['申报目的地', '申报地点', '明细地点', '发生地点', '意图城市', '目的地'].some(
|
||||
(term) => normalized.includes(term)
|
||||
)
|
||||
const hasRelation = ['一致', '不一致', '形成一致关系', '匹配', '无法与', '对应'].some((term) =>
|
||||
normalized.includes(term)
|
||||
)
|
||||
const hasRouteAnomaly = ['绕行', '跨城', '中转', '周转', '改签'].some((term) =>
|
||||
normalized.includes(term)
|
||||
)
|
||||
return hasCitySubject && hasReference && (hasRelation || hasRouteAnomaly)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './t
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
import {
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_START_APPLICATION,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
@@ -163,7 +164,7 @@ export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
||||
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '快速发起申请',
|
||||
prompt: '我想快速发起一笔费用申请,请先帮我判断申请类型并引导补充信息。',
|
||||
action: GUIDED_ACTION_START_APPLICATION,
|
||||
icon: 'mdi mdi-file-plus-outline'
|
||||
},
|
||||
{
|
||||
@@ -252,6 +253,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
reviewPanelScope: '',
|
||||
riskFlags: [],
|
||||
pendingAttachmentAssociation: null,
|
||||
applicationPreview: null,
|
||||
...extras
|
||||
}
|
||||
}
|
||||
@@ -801,6 +803,7 @@ export function serializeSessionMessages(messages) {
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
assistantName: message.assistantName || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
export const EXPENSE_QUERY_PAGE_SIZE = 5
|
||||
export const EXPENSE_CENTER_HREF = '/app/requests'
|
||||
export const EXPENSE_CENTER_HREF = '/app/documents'
|
||||
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
|
||||
const EXPENSE_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
@@ -282,7 +282,7 @@ export function buildExpenseQueryHint(queryPayload) {
|
||||
if (queryPayload.selectionLocked && queryPayload.selectedClaimId) {
|
||||
return '已选择关联草稿,附件将按该单据继续识别和归集。'
|
||||
}
|
||||
return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。'
|
||||
return '如果这些都不是本次要关联的单据,可以补充单号或先到单据中心新建草稿。'
|
||||
}
|
||||
|
||||
const parts = []
|
||||
@@ -290,9 +290,9 @@ export function buildExpenseQueryHint(queryPayload) {
|
||||
const totalCount = Math.max(0, Number(queryPayload.recordCount || 0))
|
||||
|
||||
if (totalCount > previewLimit) {
|
||||
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到报销中心查看。`)
|
||||
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到单据中心查看。`)
|
||||
} else if (totalCount > 0) {
|
||||
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入报销中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
|
||||
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入单据中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
|
||||
}
|
||||
|
||||
return parts.join('。')
|
||||
|
||||
@@ -3,6 +3,7 @@ export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
|
||||
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
|
||||
|
||||
export const GUIDED_ACTION_START_REIMBURSEMENT = 'start_guided_reimbursement'
|
||||
export const GUIDED_ACTION_START_APPLICATION = 'start_guided_application'
|
||||
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
|
||||
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
|
||||
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
|
||||
@@ -445,7 +446,7 @@ export function resolveGuidedQueryModeFromText(text) {
|
||||
if (!normalized) return ''
|
||||
const exact = GUIDED_QUERY_MODES.find((item) => normalized === item.label || normalized === item.key)
|
||||
if (exact) return exact.key
|
||||
if (/单号|编号|EXP-/i.test(normalized)) return 'claim_no'
|
||||
if (/单号|编号|EXP-|APP-|AP-|RE-|AD-/i.test(normalized)) return 'claim_no'
|
||||
if (/状态|草稿|审批|退回|归档|完成/.test(normalized)) return 'status'
|
||||
if (/上周|本周|去年|今年|月份|时间|日期|[0-9]{4}-[0-9]{2}/.test(normalized)) return 'time_range'
|
||||
return 'keyword'
|
||||
@@ -484,7 +485,7 @@ export function buildGuidedQueryPromptText(state) {
|
||||
].join('\n')
|
||||
}
|
||||
const prompts = {
|
||||
claim_no: '请输入报销单号,例如 EXP-202605-001。',
|
||||
claim_no: '请输入单据编号,例如 RE-20260525103045-ABCDEFGH。',
|
||||
time_range: '请输入查询时间范围,例如:上周、今年 5 月、2025 年全年。',
|
||||
keyword: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function isApplicationDocumentRequest(request) {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
|
||||
105
web/src/views/scripts/useApplicationPreviewEditor.js
Normal file
105
web/src/views/scripts/useApplicationPreviewEditor.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
buildApplicationPreviewRows,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
|
||||
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
|
||||
const applicationPreviewEditor = ref({
|
||||
messageId: '',
|
||||
fieldKey: '',
|
||||
draftValue: ''
|
||||
})
|
||||
|
||||
function resolveApplicationPreviewRows(message) {
|
||||
return buildApplicationPreviewRows(message?.applicationPreview || {})
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorControl(fieldKey) {
|
||||
return fieldKey === 'transportMode' ? 'select' : 'text'
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorOptions(fieldKey) {
|
||||
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
|
||||
}
|
||||
|
||||
function isApplicationPreviewEditing(message, fieldKey) {
|
||||
return (
|
||||
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
|
||||
applicationPreviewEditor.value.fieldKey === fieldKey
|
||||
)
|
||||
}
|
||||
|
||||
function openApplicationPreviewEditor(message, fieldKey, value) {
|
||||
if (!message?.applicationPreview || !fieldKey) return
|
||||
const targetRow = buildApplicationPreviewRows(message.applicationPreview)
|
||||
.find((row) => row.key === fieldKey)
|
||||
if (targetRow && targetRow.editable === false) return
|
||||
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
|
||||
applicationPreviewEditor.value = {
|
||||
messageId: String(message.id || ''),
|
||||
fieldKey,
|
||||
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||
? ''
|
||||
: normalizedValue
|
||||
}
|
||||
}
|
||||
|
||||
function cancelApplicationPreviewEditor() {
|
||||
applicationPreviewEditor.value = {
|
||||
messageId: '',
|
||||
fieldKey: '',
|
||||
draftValue: ''
|
||||
}
|
||||
}
|
||||
|
||||
function commitApplicationPreviewEditor(message) {
|
||||
const editor = applicationPreviewEditor.value
|
||||
if (!message?.applicationPreview || String(editor.messageId || '') !== String(message.id || '') || !editor.fieldKey) {
|
||||
cancelApplicationPreviewEditor()
|
||||
return false
|
||||
}
|
||||
|
||||
const nextValue = String(editor.draftValue || '').trim()
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
...message.applicationPreview,
|
||||
fields: {
|
||||
...(message.applicationPreview.fields || {}),
|
||||
[editor.fieldKey]: nextValue
|
||||
}
|
||||
})
|
||||
message.applicationPreview = nextPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(nextPreview)
|
||||
cancelApplicationPreviewEditor()
|
||||
persistSessionState?.()
|
||||
toast?.('已更新核对表内容。')
|
||||
return true
|
||||
}
|
||||
|
||||
function handleApplicationPreviewEditorKeydown(event, message) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitApplicationPreviewEditor(message)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelApplicationPreviewEditor()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applicationPreviewEditor,
|
||||
resolveApplicationPreviewRows,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
buildApplicationTemplatePreview,
|
||||
buildLocalApplicationPreviewMessage
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
GUIDED_ACTION_START_APPLICATION,
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
@@ -84,6 +89,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
openTravelCalculator,
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer,
|
||||
currentUser,
|
||||
toast
|
||||
}) {
|
||||
const guidedPendingFiles = ref([])
|
||||
@@ -134,6 +140,16 @@ export function useTravelReimbursementGuidedFlow({
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function startGuidedApplicationTemplate() {
|
||||
resetGuidedFlowState()
|
||||
const applicationPreview = buildApplicationTemplatePreview(currentUser?.value || {})
|
||||
pushAssistant(buildLocalApplicationPreviewMessage(applicationPreview), {
|
||||
meta: ['申请模板'],
|
||||
applicationPreview
|
||||
})
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function startGuidedStatusQuery() {
|
||||
guidedFlowState.value = createGuidedStatusQueryState()
|
||||
guidedPendingFiles.value = []
|
||||
@@ -146,6 +162,10 @@ export function useTravelReimbursementGuidedFlow({
|
||||
|
||||
function handleGuidedShortcut(shortcut) {
|
||||
const actionType = normalizeText(shortcut?.action)
|
||||
if (actionType === GUIDED_ACTION_START_APPLICATION) {
|
||||
startGuidedApplicationTemplate()
|
||||
return true
|
||||
}
|
||||
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
|
||||
startGuidedReimbursement()
|
||||
return true
|
||||
|
||||
@@ -4,6 +4,18 @@ import {
|
||||
buildUnsavedDraftAttachmentConfirmationMessage
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
applyApplicationPolicyEstimateError,
|
||||
applyApplicationPolicyEstimateResult,
|
||||
buildApplicationPolicyEstimateRequest,
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
@@ -46,6 +58,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
fetchExpenseClaims,
|
||||
fileInputRef,
|
||||
flowRunId,
|
||||
insightPanelCollapsed,
|
||||
isKnowledgeSession,
|
||||
linkedRequest,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
@@ -281,6 +294,73 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
).trim()
|
||||
}
|
||||
|
||||
function buildApplicationPreviewReviewMeta(ontology) {
|
||||
return [
|
||||
'申请核对预览',
|
||||
String(ontology?.parse_strategy || '').trim() === 'llm_primary'
|
||||
? '模型复核完成'
|
||||
: '规则兜底复核'
|
||||
]
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText) {
|
||||
const user = currentUser.value || {}
|
||||
const localPreview = buildLocalApplicationPreview(rawText, user)
|
||||
|
||||
const enrichWithPolicyEstimate = async (preview) => {
|
||||
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
|
||||
if (!estimateRequest.canCalculate) {
|
||||
return preview
|
||||
}
|
||||
try {
|
||||
const result = await calculateTravelReimbursement(estimateRequest.payload)
|
||||
return applyApplicationPolicyEstimateResult(preview, result, user)
|
||||
} catch (error) {
|
||||
console.warn('Application policy estimate failed:', error)
|
||||
return applyApplicationPolicyEstimateError(preview, error, user)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ontology = await fetchOntologyParse(
|
||||
{
|
||||
query: rawText,
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
context_json: {
|
||||
...buildExpenseApplicationOntologyContext(user),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
user_input_text: rawText
|
||||
}
|
||||
},
|
||||
{
|
||||
timeoutMs: 45000,
|
||||
timeoutMessage: '模型抽取申请字段超时,已保留当前本地预览。'
|
||||
}
|
||||
)
|
||||
|
||||
const refinedPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
ontology,
|
||||
rawText,
|
||||
user
|
||||
)
|
||||
return {
|
||||
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
|
||||
meta: buildApplicationPreviewReviewMeta(ontology)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Application preview model refinement failed:', error)
|
||||
return {
|
||||
applicationPreview: await enrichWithPolicyEstimate({
|
||||
...localPreview,
|
||||
modelReviewStatus: 'failed'
|
||||
}),
|
||||
meta: ['申请核对预览', '模型复核失败']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComposer(options = {}) {
|
||||
if (submitting.value || sessionSwitchBusy.value) return null
|
||||
|
||||
@@ -388,6 +468,84 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (shouldUseLocalApplicationPreview(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
systemGenerated
|
||||
})) {
|
||||
const intentStartedAt = Date.now()
|
||||
const reviewStartedAt = intentStartedAt
|
||||
resetFlowRun()
|
||||
startFlowStep('intent', {
|
||||
title: '业务意图识别',
|
||||
tool: 'ontology.intent_detection',
|
||||
detail: '正在识别是否为费用申请事项...'
|
||||
})
|
||||
startFlowStep('application-review-preview', {
|
||||
title: '申请信息核对',
|
||||
tool: 'ontology.application_review',
|
||||
detail: '正在进行申请信息模型复核...'
|
||||
})
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
'正在进行申请信息模型复核。本步骤只识别意图和抽取字段,不会创建、更新或保存草稿。',
|
||||
[],
|
||||
{
|
||||
meta: ['模型复核中']
|
||||
}
|
||||
)
|
||||
messages.value.push(pendingMessage)
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText)
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
'application-review-preview',
|
||||
reviewStatus === '模型复核完成'
|
||||
? '模型复核完成,已生成申请核对表'
|
||||
: reviewStatus === '模型复核失败'
|
||||
? '模型复核失败,已生成临时核对表'
|
||||
: '模型未返回稳定结果,已完成规则兜底核对',
|
||||
Date.now() - reviewStartedAt
|
||||
)
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
[],
|
||||
{
|
||||
meta,
|
||||
applicationPreview
|
||||
}
|
||||
))
|
||||
if (insightPanelCollapsed) {
|
||||
insightPanelCollapsed.value = true
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const hasUnsavedReviewDraft = Boolean(
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
canApproveLeaderExpenseClaims,
|
||||
canAccessAppView,
|
||||
canDeleteArchivedExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
canReturnExpenseClaims
|
||||
} from '../src/utils/accessControl.js'
|
||||
@@ -28,6 +30,21 @@ test('finance can return and final approve, but only executives can manage delet
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
|
||||
})
|
||||
|
||||
test('archived claims can only be deleted by admin users', () => {
|
||||
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['executive'] }), false)
|
||||
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['finance'] }), false)
|
||||
assert.equal(canDeleteArchivedExpenseClaims({ isAdmin: true, roleCodes: ['manager'] }), true)
|
||||
})
|
||||
|
||||
test('legacy reimbursement approval and archive centers are no longer accessible app views', () => {
|
||||
const adminUser = { isAdmin: true, roleCodes: ['manager', 'finance'] }
|
||||
|
||||
assert.equal(canAccessAppView(adminUser, 'requests'), false)
|
||||
assert.equal(canAccessAppView(adminUser, 'approval'), false)
|
||||
assert.equal(canAccessAppView(adminUser, 'archive'), false)
|
||||
assert.equal(canAccessAppView(adminUser, 'documents'), true)
|
||||
})
|
||||
|
||||
test('finance approval inbox only processes finance-stage requests', () => {
|
||||
const financeUser = { roleCodes: ['finance'], name: '财务' }
|
||||
|
||||
|
||||
@@ -101,8 +101,8 @@ test('detail topbar still flags real manual rows without required ticket info',
|
||||
|
||||
test('application detail topbar does not ask for receipt attachments', () => {
|
||||
const request = {
|
||||
id: 'APP-20260525-ABC123',
|
||||
claimNo: 'APP-20260525-ABC123',
|
||||
id: 'AP-20260525103045-ABCDEFGH',
|
||||
claimNo: 'AP-20260525103045-ABCDEFGH',
|
||||
documentTypeCode: 'application',
|
||||
node: '直属领导审批',
|
||||
approvalKey: 'in_progress',
|
||||
|
||||
@@ -53,6 +53,16 @@ test('financial assistant toolbar renders four isolated assistant sessions', ()
|
||||
assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/)
|
||||
})
|
||||
|
||||
test('closing a busy assistant keeps the running instance recoverable', () => {
|
||||
assert.match(appShellRouteView, /:reopen-token="smartEntryRevealToken"/)
|
||||
assert.match(appShellComposable, /const smartEntryRevealToken = ref\(0\)/)
|
||||
assert.match(appShellComposable, /if \(smartEntryOpen\.value\) \{\s*smartEntryRevealToken\.value \+= 1\s*return\s*\}/)
|
||||
assert.match(appShellComposable, /smartEntryRevealToken,/)
|
||||
assert.match(assistantScript, /reopenToken:\s*\{\s*type:\s*Number/)
|
||||
assert.match(assistantScript, /closeAfterBusy\.value = false[\s\S]*workbenchVisible\.value = true/)
|
||||
assert.match(assistantScript, /function emitCloseAfterLeave\(\) \{\s*if \(workbenchVisible\.value\)/)
|
||||
})
|
||||
|
||||
test('financial assistant welcome copy differentiates application intent from reimbursement entry', () => {
|
||||
const user = { name: '李文静', username: 'wenjing.li', grade: 'P5' }
|
||||
const applicationWelcome = buildWelcomeMessage('application', null, SESSION_TYPE_APPLICATION, user)
|
||||
|
||||
54
web/tests/application-approval-info.test.mjs
Normal file
54
web/tests/application-approval-info.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildLeaderApprovalInfo,
|
||||
resolveGeneratedDraftClaimNo
|
||||
} from '../src/utils/applicationApproval.js'
|
||||
|
||||
test('buildLeaderApprovalInfo extracts leader opinion and generated reimbursement draft', () => {
|
||||
const info = buildLeaderApprovalInfo({
|
||||
profileManager: '王经理',
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'manual_return',
|
||||
opinion: '需要补充预算口径',
|
||||
created_at: '2026-05-24T09:00:00'
|
||||
},
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'li.manager@example.com',
|
||||
operator_name: '李经理',
|
||||
opinion: '业务必要,同意申请。',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '审批完成',
|
||||
generated_draft_claim_no: 'EXP-202605-0007',
|
||||
created_at: '2026-05-25T10:15:00'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(info, {
|
||||
opinion: '业务必要,同意申请。',
|
||||
operator: '李经理',
|
||||
time: '2026-05-25 10:15',
|
||||
generatedDraftClaimNo: 'EXP-202605-0007'
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveGeneratedDraftClaimNo reads approval response payload', () => {
|
||||
assert.equal(
|
||||
resolveGeneratedDraftClaimNo({
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
generated_draft_claim_no: 'EXP-202605-0012',
|
||||
created_at: '2026-05-25T11:00:00'
|
||||
}
|
||||
]
|
||||
}),
|
||||
'EXP-202605-0012'
|
||||
)
|
||||
})
|
||||
@@ -90,12 +90,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
|
||||
|
||||
assert.ok(handleDraftSavedBlock)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: activeView\.value === 'documents' \? 'app-documents' : 'app-requests' \}\)/)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
|
||||
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
|
||||
|
||||
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
|
||||
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
|
||||
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })", draftSuccessIndex), -1)
|
||||
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1)
|
||||
})
|
||||
|
||||
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ test('suggested action prefill falls back to application field templates', () =>
|
||||
action_type: 'prefill_composer',
|
||||
payload: { application_field: 'amount' }
|
||||
}),
|
||||
'预计总费用:'
|
||||
'用户预估费用:'
|
||||
)
|
||||
assert.equal(
|
||||
resolveSuggestedActionPrefill({
|
||||
|
||||
@@ -257,7 +257,7 @@ test('expense query info items render as prompts instead of low risk', () => {
|
||||
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
|
||||
})
|
||||
|
||||
test('expense query hint guides users to the reimbursement center after the top five results', () => {
|
||||
test('expense query hint guides users to the document center after the top five results', () => {
|
||||
const payload = normalizeExpenseQueryPayload({
|
||||
result_type: 'expense_claim_list',
|
||||
title: '最近 5 条你的归档报销单',
|
||||
|
||||
50
web/tests/document-center-archived-scope.test.mjs
Normal file
50
web/tests/document-center-archived-scope.test.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
excludeArchivedDocumentRows,
|
||||
isArchivedDocumentRow
|
||||
} from '../src/utils/documentCenterRows.js'
|
||||
|
||||
test('document center archived rows are detected from archive flag or request stage', () => {
|
||||
assert.equal(isArchivedDocumentRow({ archived: true }), true)
|
||||
assert.equal(
|
||||
isArchivedDocumentRow({
|
||||
rawRequest: { status: 'approved', approval_stage: '归档入账' }
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedDocumentRow({
|
||||
rawRequest: {
|
||||
status: 'approved',
|
||||
approval_stage: '审批完成',
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedDocumentRow({
|
||||
rawRequest: { status: 'in_progress', approval_stage: '部门审批' }
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedDocumentRow({
|
||||
rawRequest: { status: 'approved', approval_stage: '部门审批', approvalKey: 'completed' }
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('document center all scope excludes archived rows from merged lists', () => {
|
||||
const rows = excludeArchivedDocumentRows([
|
||||
{ claimId: 'a', archived: true },
|
||||
{ claimId: 'b', rawRequest: { status: 'approved', approval_stage: '归档入账' } },
|
||||
{ claimId: 'c', rawRequest: { status: 'submitted', approval_stage: '部门审批' } }
|
||||
])
|
||||
|
||||
assert.deepEqual(rows.map((row) => row.claimId), ['c'])
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveDocumentNewKey,
|
||||
writeDocumentScope
|
||||
} from '../src/utils/documentCenterNewState.js'
|
||||
import { buildDocumentInboxRows } from '../src/composables/useDocumentCenterInbox.js'
|
||||
|
||||
function createMemoryStorage(initial = {}) {
|
||||
const store = new Map(Object.entries(initial))
|
||||
@@ -46,6 +47,16 @@ test('document center new state counts unseen documents and persists viewed rows
|
||||
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
|
||||
})
|
||||
|
||||
test('document center sidebar inbox shares source scoped document keys', () => {
|
||||
const rows = buildDocumentInboxRows({
|
||||
ownedClaims: [{ id: 'claim-1', claim_no: 'EXP-1' }],
|
||||
approvalClaims: [{ id: 'claim-1', claim_no: 'EXP-1' }],
|
||||
archivedClaims: [{ id: 'claim-2', claim_no: 'EXP-2' }]
|
||||
})
|
||||
|
||||
assert.deepEqual(rows.map((row) => resolveDocumentNewKey(row)), ['approval:claim-1', 'archive:claim-2'])
|
||||
})
|
||||
|
||||
test('document center scope state restores only allowed tabs', () => {
|
||||
const storage = createMemoryStorage()
|
||||
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
|
||||
|
||||
@@ -39,7 +39,10 @@ test('documents center top tabs start from all and show document category labels
|
||||
})
|
||||
|
||||
test('documents center category tabs map to the intended row sources', () => {
|
||||
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
|
||||
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
|
||||
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
|
||||
assert.match(documentsCenterView, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/
|
||||
|
||||
291
web/tests/expense-application-fast-preview.test.mjs
Normal file
291
web/tests/expense-application-fast-preview.test.mjs
Normal file
@@ -0,0 +1,291 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildApplicationPreviewFooterMessage,
|
||||
buildApplicationPreviewRows,
|
||||
buildApplicationPreviewSubmitText,
|
||||
buildApplicationTemplatePreview,
|
||||
applyApplicationPolicyEstimateResult,
|
||||
buildApplicationPolicyEstimateRequest,
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
normalizeApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const previewEditorScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('application intent uses local preview instead of immediate orchestrator call', () => {
|
||||
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差3天,高铁,预计金额2358元'
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview(prompt, {
|
||||
sessionType: 'application',
|
||||
attachmentCount: 0,
|
||||
reviewAction: '',
|
||||
systemGenerated: false
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
shouldUseLocalApplicationPreview('帮我查询申请状态', {
|
||||
sessionType: 'application',
|
||||
attachmentCount: 0,
|
||||
reviewAction: '',
|
||||
systemGenerated: false
|
||||
}),
|
||||
false
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', 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.grade, 'P5')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||
})
|
||||
|
||||
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.location, '新疆,伊犁')
|
||||
assert.equal(preview.fields.reason, '服务美团业务部署')
|
||||
const editedPreview = normalizeApplicationPreview({
|
||||
...preview,
|
||||
fields: {
|
||||
...preview.fields,
|
||||
reason: '客户现场项目支持',
|
||||
amount: '1900元'
|
||||
}
|
||||
})
|
||||
|
||||
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 === 'lodgingDailyCap')?.editable, false)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用:1900元/)
|
||||
})
|
||||
|
||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||
const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
|
||||
assert.equal(preview.fields.location, '九江')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.fields.reason, '服务美团业务部署')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.doesNotMatch(preview.fields.reason, /发生时间|去九江|出差3天/)
|
||||
})
|
||||
|
||||
test('application preview can be refined by ontology model extraction', () => {
|
||||
const rawText = '发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||
const refinedPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
{
|
||||
parse_strategy: 'llm_primary',
|
||||
entities: [
|
||||
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
|
||||
{ type: 'location', value: '九江', normalized_value: '九江' },
|
||||
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' },
|
||||
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
|
||||
{ type: 'amount', value: '1800元', normalized_value: '1800' }
|
||||
],
|
||||
time_range: {},
|
||||
missing_slots: []
|
||||
},
|
||||
rawText,
|
||||
{ name: '李文静', grade: 'P5' }
|
||||
)
|
||||
|
||||
assert.equal(refinedPreview.modelRefined, true)
|
||||
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
|
||||
assert.equal(refinedPreview.modelReviewStatus, 'completed')
|
||||
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(refinedPreview.fields.time, '')
|
||||
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
|
||||
assert.equal(refinedPreview.fields.transportMode, '火车')
|
||||
})
|
||||
|
||||
test('application preview keeps rule fallback distinct from model reviewed result', () => {
|
||||
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差3天,火车,预计费用1800元'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||
const fallbackPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
{
|
||||
parse_strategy: 'rule_fallback',
|
||||
entities: [
|
||||
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
|
||||
{ type: 'location', value: '上海', normalized_value: '上海' },
|
||||
{ type: 'amount', value: '1800元', normalized_value: '1800' }
|
||||
],
|
||||
time_range: {
|
||||
start: '2026-05-20',
|
||||
end: '2026-05-23'
|
||||
},
|
||||
missing_slots: []
|
||||
},
|
||||
rawText,
|
||||
{ name: '李文静', grade: 'P5' }
|
||||
)
|
||||
const message = buildLocalApplicationPreviewMessage(fallbackPreview)
|
||||
const footer = buildApplicationPreviewFooterMessage(fallbackPreview)
|
||||
|
||||
assert.equal(fallbackPreview.modelReviewStatus, 'fallback')
|
||||
assert.match(message, /规则兜底/)
|
||||
assert.match(footer, /规则兜底/)
|
||||
assert.doesNotMatch(footer, /#application-submit/)
|
||||
})
|
||||
|
||||
test('application preview with missing budget stays in chat and asks for补充信息', () => {
|
||||
const preview = buildLocalApplicationPreview('我想申请去北京出差,高铁,但是不知道预算', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.amount, '待测算')
|
||||
assert.equal(preview.readyToSubmit, false)
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /下方表格/)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /当前还需要补充/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
|
||||
})
|
||||
|
||||
test('application quick start renders a template without model review', () => {
|
||||
const preview = buildApplicationTemplatePreview({
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
grade: 'P5'
|
||||
})
|
||||
const message = buildLocalApplicationPreviewMessage(preview)
|
||||
|
||||
assert.equal(preview.modelReviewStatus, 'template')
|
||||
assert.equal(preview.fields.applicationType, '费用申请')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.match(message, /不调用大模型/)
|
||||
assert.match(message, /点击对应行直接填写/)
|
||||
assert.doesNotMatch(message, /#application-submit/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
|
||||
})
|
||||
|
||||
test('application session shows intent flow, persists preview, and supports inline table edit', () => {
|
||||
assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/)
|
||||
assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(submitComposerScript, /buildApplicationPreviewWithModelReview/)
|
||||
assert.match(submitComposerScript, /fetchOntologyParse/)
|
||||
assert.match(submitComposerScript, /calculateTravelReimbursement/)
|
||||
assert.match(submitComposerScript, /buildApplicationPolicyEstimateRequest/)
|
||||
assert.match(submitComposerScript, /模型复核中/)
|
||||
assert.match(submitComposerScript, /startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
|
||||
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /insightPanelCollapsed\.value = true/)
|
||||
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
|
||||
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
|
||||
assert.ok(
|
||||
submitComposerScript.indexOf('shouldUseLocalApplicationPreview') <
|
||||
submitComposerScript.indexOf('const payload = await runOrchestrator')
|
||||
)
|
||||
|
||||
assert.match(createViewScript, /const isApplicationSession = computed/)
|
||||
assert.match(createViewScript, /insightPanelCollapsed,/)
|
||||
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
||||
assert.match(createViewScript, /flowSteps\.value\.length > 0/)
|
||||
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
||||
assert.match(createViewScript, /message-bubble-application-preview/)
|
||||
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
|
||||
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
|
||||
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
|
||||
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
|
||||
assert.match(conversationModelScript, /applicationPreview: null/)
|
||||
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
|
||||
|
||||
assert.match(createViewTemplate, /class="application-preview-table"/)
|
||||
assert.match(createViewTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
|
||||
assert.match(createViewTemplate, /v-html="renderMarkdown\(buildApplicationPreviewFooterText\(message\)\)"/)
|
||||
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
|
||||
assert.match(createViewTemplate, /v-model="applicationPreviewEditor\.draftValue"/)
|
||||
assert.match(createViewTemplate, /application-preview-select/)
|
||||
assert.match(createViewTemplate, /resolveApplicationPreviewEditorOptions/)
|
||||
assert.match(createViewTemplate, /row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
|
||||
assert.match(createViewTemplate, /@keydown\.enter\.prevent="row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
|
||||
assert.match(createViewTemplate, /@keydown\.stop="handleApplicationPreviewEditorKeydown\(\$event, message\)"/)
|
||||
assert.match(createViewTemplate, /mdi mdi-pencil-outline/)
|
||||
assert.match(createViewTemplate, /@click\.stop="openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
|
||||
assert.match(createViewTemplate, /openApplicationPreviewEditor/)
|
||||
assert.match(createViewTemplate, /commitApplicationPreviewEditor/)
|
||||
|
||||
assert.match(previewEditorScript, /normalizeApplicationPreview/)
|
||||
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
|
||||
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(previewEditorScript, /targetRow\.editable === false/)
|
||||
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
|
||||
})
|
||||
|
||||
test('application preview merges rule center travel estimate into highlighted rows', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天,服务项目部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
})
|
||||
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
|
||||
assert.equal(request.canCalculate, true)
|
||||
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
|
||||
|
||||
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
|
||||
days: 3,
|
||||
location: '上海',
|
||||
matched_city: '上海',
|
||||
grade: 'P5',
|
||||
hotel_rate: 600,
|
||||
hotel_amount: 1800,
|
||||
total_allowance_rate: 120,
|
||||
allowance_amount: 360,
|
||||
total_amount: 2160,
|
||||
rule_name: '公司差旅费报销规则',
|
||||
rule_version: '2026版'
|
||||
}, { grade: 'P5' })
|
||||
|
||||
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.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
|
||||
})
|
||||
@@ -43,6 +43,22 @@ test('expense application prompt field parser supports multiline labels', () =>
|
||||
assert.equal(resolveApplicationReason(structuredApplicationPrompt), '支撑国网服务器部署')
|
||||
})
|
||||
|
||||
test('expense application reason prefers model entity and strips context fragments', () => {
|
||||
assert.equal(
|
||||
resolveApplicationReason(
|
||||
'发生时间:,去九江出差3天,服务美团业务部署',
|
||||
{
|
||||
entities: [
|
||||
{ type: 'location', value: '九江', normalized_value: '九江' },
|
||||
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' }
|
||||
]
|
||||
}
|
||||
),
|
||||
'服务美团业务部署'
|
||||
)
|
||||
assert.equal(resolveApplicationReason('发生时间:,去九江出差3天,服务美团业务部署'), '服务美团业务部署')
|
||||
})
|
||||
|
||||
test('expense application expands a single selected date with natural days', () => {
|
||||
const prompt = [
|
||||
'发生时间:2026-05-25',
|
||||
|
||||
@@ -33,7 +33,7 @@ test('expense application submit uses rich text link and confirm dialog', () =>
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/async function confirmApplicationSubmit\(\)[\s\S]*rawText: '确认提交'[\s\S]*systemGenerated: true/
|
||||
/async function confirmApplicationSubmit\(\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
|
||||
@@ -10,6 +10,15 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => {
|
||||
isArchivedExpenseClaim({ status: 'approved', approval_stage: '归档入账' }),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedExpenseClaim({
|
||||
status: 'approved',
|
||||
approval_stage: '审批完成',
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('isArchivedExpenseClaim ignores in-progress claims', () => {
|
||||
@@ -19,7 +28,7 @@ test('isArchivedExpenseClaim ignores in-progress claims', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('archive center is wired into navigation and api client', () => {
|
||||
test('archive data stays available through api client but archive center is removed from navigation', () => {
|
||||
const navigationScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useNavigation.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -29,7 +38,7 @@ test('archive center is wired into navigation and api client', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(navigationScript, /id:\s*'archive'/)
|
||||
assert.doesNotMatch(navigationScript, /id:\s*'archive'/)
|
||||
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
|
||||
})
|
||||
|
||||
@@ -43,10 +52,13 @@ test('archive center uses generic archive category and type wording', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT\]/)
|
||||
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_APPLICATION, ARCHIVE_TAB_REIMBURSEMENT\]/)
|
||||
assert.match(archiveScript, /const ARCHIVE_TAB_APPLICATION = '申请归档'/)
|
||||
assert.match(archiveScript, /const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'/)
|
||||
assert.match(archiveScript, /archiveType:\s*ARCHIVE_TYPE_REIMBURSEMENT/)
|
||||
assert.match(archiveScript, /archiveTypeCode:\s*ARCHIVE_TYPE_REIMBURSEMENT_CODE/)
|
||||
assert.match(archiveScript, /archiveType:\s*isApplicationDocument \? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT/)
|
||||
assert.match(archiveScript, /archiveTab:\s*isApplicationDocument \? ARCHIVE_TAB_APPLICATION : ARCHIVE_TAB_REIMBURSEMENT/)
|
||||
assert.match(archiveScript, /const ARCHIVE_TYPE_REIMBURSEMENT = '报销'/)
|
||||
assert.match(archiveScript, /const ARCHIVE_TYPE_APPLICATION = '申请'/)
|
||||
assert.doesNotMatch(archiveScript, /'差旅报销'/)
|
||||
assert.doesNotMatch(archiveScript, /'招待报销'/)
|
||||
assert.doesNotMatch(archiveScript, /'其他费用'/)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
appViews,
|
||||
navItems,
|
||||
resolveAppViewFromRoute,
|
||||
resolveTargetRouteName
|
||||
} from '../src/composables/useNavigation.js'
|
||||
|
||||
function testDerivesViewFromRouteName() {
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'requests')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'documents')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
|
||||
}
|
||||
|
||||
@@ -19,13 +21,24 @@ function testFallsBackToValidMeta() {
|
||||
function testResolvesMainRouteNames() {
|
||||
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
|
||||
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
|
||||
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
|
||||
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
|
||||
assert.equal(resolveTargetRouteName('archive'), 'app-overview')
|
||||
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
|
||||
}
|
||||
|
||||
function testLegacyCentersAreRemovedFromNavigation() {
|
||||
assert.equal(appViews.includes('requests'), false)
|
||||
assert.equal(appViews.includes('approval'), false)
|
||||
assert.equal(appViews.includes('archive'), false)
|
||||
assert.equal(navItems.some((item) => ['requests', 'approval', 'archive'].includes(item.id)), false)
|
||||
}
|
||||
|
||||
function run() {
|
||||
testDerivesViewFromRouteName()
|
||||
testFallsBackToValidMeta()
|
||||
testResolvesMainRouteNames()
|
||||
testLegacyCentersAreRemovedFromNavigation()
|
||||
console.log('navigation route resolution tests passed')
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||
test('application claims are mapped as application documents', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-1',
|
||||
claim_no: 'APP-20260525-ABC123',
|
||||
claim_no: 'AP-20260525103045-ABCDEFGH',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
expense_type: 'travel_application',
|
||||
@@ -42,7 +42,7 @@ test('application claims are mapped as application documents', () => {
|
||||
test('approved application claims complete after direct manager approval only', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-approved',
|
||||
claim_no: 'APP-20260525-DONE01',
|
||||
claim_no: 'AP-20260525113045-HGFEDCBA',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: '李经理',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { normalizeRequestForUi } from '../src/utils/requestViewModel.js'
|
||||
import { isArchivedRequestView, normalizeRequestForUi } from '../src/utils/requestViewModel.js'
|
||||
|
||||
test('normalizes backend approval_stage for in-progress claim details', () => {
|
||||
const request = normalizeRequestForUi({
|
||||
@@ -45,3 +45,42 @@ test('does not show manager email as direct supervisor name', () => {
|
||||
|
||||
assert.equal(request.profileManager, '待补充')
|
||||
})
|
||||
|
||||
test('detects archived claim view models for delete permission gating', () => {
|
||||
assert.equal(
|
||||
isArchivedRequestView({
|
||||
status: 'approved',
|
||||
approval_stage: '归档入账',
|
||||
approvalKey: 'completed'
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedRequestView({
|
||||
status: 'submitted',
|
||||
approval_stage: '财务审批',
|
||||
approvalKey: 'in_progress'
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedRequestView({
|
||||
status: 'approved',
|
||||
approval_stage: '审批完成',
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application',
|
||||
approvalKey: 'completed'
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedRequestView({
|
||||
status: 'approved',
|
||||
approval_stage: '审批完成',
|
||||
claim_no: 'RE-20260525120000-HGFEDCBA',
|
||||
expense_type: 'travel',
|
||||
approvalKey: 'completed'
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
43
web/tests/sidebar-document-unread-dot.test.mjs
Normal file
43
web/tests/sidebar-document-unread-dot.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const sidebar = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const documentInbox = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const documentNewState = readFileSync(
|
||||
fileURLToPath(new URL('../src/utils/documentCenterNewState.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('sidebar renders a red dot for unread document center rows', () => {
|
||||
assert.match(sidebar, /useDocumentCenterInbox/)
|
||||
assert.match(sidebar, /hasUnread: documentInboxHasUnread/)
|
||||
assert.match(sidebar, /<span v-if="item\.hasNewMessage" class="nav-unread-dot" aria-hidden="true"><\/span>/)
|
||||
assert.match(sidebar, /hasNewMessage: item\.id === 'documents' \? documentInboxHasUnread\.value : false/)
|
||||
assert.match(sidebar, /void refreshDocumentInbox\(\)/)
|
||||
assert.match(sidebar, /startDocumentInboxPolling\(\)/)
|
||||
assert.match(sidebar, /stopDocumentInboxPolling\(\)/)
|
||||
assert.match(sidebar, /\.nav-unread-dot\s*\{[\s\S]*background:\s*#ef4444;/)
|
||||
assert.match(sidebar, /\.rail-collapsed \.nav-unread-dot\s*\{[\s\S]*position:\s*absolute;/)
|
||||
})
|
||||
|
||||
test('document inbox reuses document center viewed-key state', () => {
|
||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentInbox, /readViewedDocumentKeys/)
|
||||
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(documentInbox, /fetchExpenseClaims/)
|
||||
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
|
||||
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
|
||||
assert.match(documentInbox, /window\.addEventListener\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys\)/)
|
||||
assert.match(documentNewState, /export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentNewState, /window\.dispatchEvent\(new CustomEvent\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT\)\)/)
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_START_APPLICATION,
|
||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
@@ -90,6 +91,16 @@ test('assistant session modes expose independent quick actions', () => {
|
||||
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
|
||||
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||||
)
|
||||
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].action, GUIDED_ACTION_START_APPLICATION)
|
||||
assert.ok(!buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].prompt)
|
||||
assert.ok(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
|
||||
)
|
||||
assert.ok(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
|
||||
)
|
||||
assert.match(guidedFlowScript, /GUIDED_ACTION_START_APPLICATION/)
|
||||
assert.match(guidedFlowScript, /buildApplicationTemplatePreview/)
|
||||
assert.deepEqual(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
|
||||
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||||
|
||||
@@ -182,7 +182,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
|
||||
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
|
||||
]
|
||||
}
|
||||
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/requests/claim-1' })
|
||||
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/documents/claim-1' })
|
||||
const rendered = renderMarkdown(copy)
|
||||
|
||||
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
|
||||
@@ -192,7 +192,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
|
||||
assert.doesNotMatch(copy, /#review-risk-high/)
|
||||
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
|
||||
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
|
||||
assert.match(copy, /\[快速修改单据信息\]\(\/app\/requests\/claim-1\)/)
|
||||
assert.match(copy, /\[快速修改单据信息\]\(\/app\/documents\/claim-1\)/)
|
||||
assert.doesNotMatch(rendered, /markdown-risk-link-/)
|
||||
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
|
||||
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
|
||||
@@ -209,7 +209,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
|
||||
...reviewPayload,
|
||||
risk_briefs: [{ level: 'high', title: '金额超标' }]
|
||||
},
|
||||
{ detailHref: '/app/requests/claim-1' }
|
||||
{ detailHref: '/app/documents/claim-1' }
|
||||
)
|
||||
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
|
||||
|
||||
@@ -280,7 +280,7 @@ test('review risk drawer lists risk briefs without score and posts details into
|
||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||||
)
|
||||
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
||||
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
|
||||
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
|
||||
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
|
||||
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
|
||||
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
|
||||
@@ -314,7 +314,7 @@ test('submit composer scopes the side panel to intent overview, document upload,
|
||||
assert.match(submitComposerScript, /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/)
|
||||
})
|
||||
|
||||
test('expense query answers keep one clear result structure with reimbursement center jump link', () => {
|
||||
test('expense query answers keep one clear result structure with document center jump link', () => {
|
||||
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.meta\?\.length/)
|
||||
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.suggestedActions\?\.length/)
|
||||
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
|
||||
@@ -351,12 +351,17 @@ test('closing the assistant while OCR is running defers unmount until the curren
|
||||
})
|
||||
|
||||
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
|
||||
assert.match(createViewTemplate, /v-if="canShowTravelCalculator" class="travel-calculator-anchor"/)
|
||||
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
|
||||
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
|
||||
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
|
||||
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
|
||||
assert.match(createViewScript, /calculateTravelReimbursement/)
|
||||
assert.match(createViewScript, /const canShowTravelCalculator = computed\(\(\) => activeSessionType\.value === SESSION_TYPE_EXPENSE\)/)
|
||||
assert.match(createViewScript, /function openTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
|
||||
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
|
||||
assert.match(createViewScript, /function toggleTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
|
||||
assert.match(createViewScript, /watch\(canShowTravelCalculator,[\s\S]*closeTravelCalculator\(\)/)
|
||||
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
|
||||
assert.match(createViewScript, /根据您输入的地点和天数/)
|
||||
assert.match(createViewScript, /匹配到您要出差的地区为/)
|
||||
|
||||
@@ -11,6 +11,10 @@ const detailScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -49,26 +53,44 @@ test('approval-mode detail collects leader opinion and confirms approval before
|
||||
assert.match(detailScript, /approvalOpinionTitle/)
|
||||
assert.match(detailScript, /approvalConfirmDescription/)
|
||||
assert.match(detailScript, /approvalNextStage/)
|
||||
assert.match(detailScript, /showApplicationLeaderOpinionInput/)
|
||||
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
|
||||
assert.match(detailScript, /buildLeaderApprovalInfo/)
|
||||
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
|
||||
assert.match(detailScript, /approveActionLabel/)
|
||||
assert.match(detailScript, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
|
||||
assert.match(detailScript, /请先填写领导意见,填写后才能确认审核。/)
|
||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
|
||||
assert.match(detailScript, /toast\(approvalSuccessToast\.value\)/)
|
||||
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
|
||||
|
||||
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
||||
assert.match(detailTemplate, /v-if="showApplicationLeaderOpinion"/)
|
||||
assert.match(detailTemplate, /class="application-leader-opinion"/)
|
||||
assert.match(detailTemplate, /领导意见/)
|
||||
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
|
||||
assert.match(detailTemplate, /v-model="leaderOpinion"/)
|
||||
assert.match(detailTemplate, /maxlength="500"\s+:required="requiresApprovalOpinion"/)
|
||||
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
|
||||
assert.match(detailTemplate, /@click="handleApproveRequest"/)
|
||||
assert.match(detailTemplate, /\{\{ approveBusy \? approveBusyLabel : approveActionLabel \}\}/)
|
||||
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
|
||||
assert.match(detailTemplate, /:badge="approvalConfirmBadge"/)
|
||||
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
|
||||
assert.match(detailTemplate, /confirm-text="确认通过"/)
|
||||
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
|
||||
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
|
||||
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
|
||||
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
|
||||
assert.match(detailTemplate, /:description="returnDialogDescription"/)
|
||||
|
||||
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
|
||||
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
|
||||
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
|
||||
assert.match(confirmApproveRequest, /approveExpenseClaim/)
|
||||
|
||||
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
|
||||
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)
|
||||
assert.match(detailStyles, /\.application-leader-opinion-head span \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
|
||||
|
||||
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
|
||||
assert.match(reimbursementService, /\/approve/)
|
||||
})
|
||||
|
||||
27
web/tests/travel-request-detail-responsive.test.mjs
Normal file
27
web/tests/travel-request-detail-responsive.test.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const detailStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const responsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view-part2.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('detail hero facts keep document number and date on one row on laptop screens', () => {
|
||||
assert.match(detailStyles, /\.hero-fact strong \{[\s\S]*overflow-wrap:\s*anywhere/)
|
||||
assert.match(detailStyles, /\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(240px,\s*1\.25fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
|
||||
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(280px,\s*1\.4fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
|
||||
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact strong \{[\s\S]*white-space:\s*nowrap/)
|
||||
assert.match(detailStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
|
||||
assert.match(detailStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
|
||||
assert.doesNotMatch(detailScript, /key:\s*'status'[\s\S]*label:\s*'当前状态'/)
|
||||
})
|
||||
@@ -425,8 +425,9 @@ test('expense detail table shows the amount total below detail rows', () => {
|
||||
test('additional note is shown above expense details as travel purpose text', () => {
|
||||
assert.ok(
|
||||
detailViewTemplate.indexOf('<h3>附加说明</h3>')
|
||||
< detailViewTemplate.indexOf("isApplicationDocument ? '申请预算' : '费用明细'")
|
||||
< detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
|
||||
)
|
||||
assert.match(detailViewTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
|
||||
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
|
||||
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
||||
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
|
||||
@@ -547,8 +548,12 @@ test('expense detail save is blocked while attachment recognition is running', (
|
||||
|
||||
test('application detail uses application labels instead of reimbursement labels', () => {
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请预算' : '费用明细'/)
|
||||
assert.match(detailViewTemplate, /无需补充任何报销票据/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
|
||||
assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
|
||||
assert.match(detailViewTemplate, /class="application-detail-facts"/)
|
||||
assert.match(detailViewTemplate, /applicationDetailFactItems/)
|
||||
assert.match(detailViewScript, /buildApplicationDetailFactItems/)
|
||||
assert.match(detailViewStyle, /\.application-detail-fact\.highlight strong/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
|
||||
|
||||
@@ -75,3 +75,11 @@ test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.match(detailExpenseModelScript, /label:\s*'创建单据'/)
|
||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||
})
|
||||
|
||||
test('archived detail delete action is gated by admin-only permission', () => {
|
||||
assert.match(detailViewScript, /canDeleteArchivedExpenseClaims/)
|
||||
assert.match(detailViewScript, /isArchivedRequestView/)
|
||||
assert.match(detailViewScript, /if \(isArchivedRequest\.value\) {\s*return canDeleteArchivedExpenseClaims\(currentUser\.value\)/)
|
||||
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canDeleteRequest"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user