feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View File

@@ -44,8 +44,6 @@
align-items: center;
}
.receipt-key-grid input,
.receipt-edit-field-row input,
.receipt-ocr-field input {
width: 100%;
border: 1px solid #d7e0ea;
@@ -56,15 +54,11 @@
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.receipt-key-grid input,
.receipt-edit-field-row input,
.receipt-ocr-field input {
height: 36px;
padding: 0 10px;
}
.receipt-key-grid input:focus,
.receipt-edit-field-row input:focus,
.receipt-ocr-field input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
@@ -105,6 +99,7 @@
}
.receipt-folder-detail {
min-width: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
@@ -112,99 +107,49 @@
}
.receipt-folder-detail :deep(.detail-scroll) {
min-width: 0;
min-height: 0;
display: grid;
align-content: start;
display: flex;
flex-direction: column;
gap: 16px;
padding-right: 4px;
padding-right: 0;
overflow: auto;
}
.receipt-folder-detail :deep(.detail-scroll) > * {
min-width: 0;
flex: 0 0 auto;
}
.receipt-folder-detail :deep(.detail-actions) {
flex-wrap: wrap;
margin-top: 10px;
padding-top: 10px;
}
.receipt-detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
}
.receipt-detail-title {
.receipt-folder-detail :deep(.detail-action-group) {
min-width: 0;
display: grid;
gap: 3px;
}
.receipt-detail-title strong {
color: #0f172a;
font-size: 18px;
font-weight: 850;
}
.receipt-detail-title span {
color: #0f172a;
font-size: 13px;
font-weight: 780;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receipt-detail-title p {
margin: 0;
color: #64748b;
font-size: 12px;
}
.receipt-toolbar-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.receipt-dashboard {
min-height: 0;
display: grid;
grid-template-columns: minmax(420px, 0.92fr) minmax(520px, 1.08fr);
gap: 14px;
align-items: stretch;
}
.receipt-dashboard-side {
min-height: 0;
display: grid;
gap: 14px;
}
.receipt-dashboard-bottom {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(260px, 0.95fr) minmax(320px, 1.2fr) minmax(240px, 0.85fr);
gap: 14px;
}
.receipt-folder-detail :deep(.detail-grid) {
min-height: 0;
min-width: 0;
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr);
grid-template-columns: minmax(0, .86fr) minmax(0, 1.14fr);
gap: 16px;
align-items: stretch;
align-items: start;
overflow: visible;
}
.receipt-folder-detail :deep(.detail-bottom) {
min-width: 0;
display: block;
}
.receipt-folder-detail :deep(.detail-main),
.receipt-folder-detail :deep(.detail-side) {
min-height: 0;
display: grid;
min-width: 0;
display: block;
}
.receipt-folder-detail :deep(.enterprise-detail-card .card-head) {
@@ -228,60 +173,80 @@
font-size: 12px;
}
.receipt-basic-panel,
.receipt-preview-panel,
.receipt-ocr-panel,
.receipt-status-panel,
.receipt-info-panel,
.receipt-log-panel {
.receipt-ticket-info-panel,
.receipt-association-panel {
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
}
.receipt-basic-panel {
display: block;
padding: 14px;
overflow: hidden;
}
.receipt-field-list-head {
.receipt-ticket-info-panel {
display: grid;
gap: 10px;
}
.receipt-card-actions {
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.receipt-ticket-info-panel :deep(.card-head) {
margin-bottom: 10px;
}
.receipt-ticket-info-panel input {
height: 32px;
padding: 0 9px;
}
.receipt-ticket-section {
min-width: 0;
display: grid;
gap: 10px;
}
.receipt-ticket-section + .receipt-ticket-section {
padding-top: 10px;
border-top: 1px solid #edf2f7;
}
.receipt-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.receipt-field-list-head strong {
.receipt-section-head strong {
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.receipt-field-list-head small {
.receipt-field-list-head small,
.receipt-section-head small {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.receipt-key-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.receipt-key-field,
.receipt-edit-field-row label,
.receipt-ocr-field {
display: grid;
gap: 6px;
}
.receipt-key-field span,
.receipt-edit-field-row label span,
.receipt-ocr-field span,
.receipt-static-item span,
.receipt-data-item span,
.receipt-status-item span {
.receipt-data-item span {
color: #64748b;
font-size: 12px;
font-weight: 750;
@@ -294,23 +259,20 @@
}
.receipt-static-grid,
.receipt-ocr-grid,
.receipt-status-grid,
.receipt-data-list {
display: grid;
gap: 10px;
}
.receipt-static-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 14px;
padding-top: 12px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #edf2f7;
}
.receipt-static-item,
.receipt-data-item,
.receipt-status-item {
.receipt-data-item {
min-width: 0;
display: grid;
gap: 4px;
@@ -326,110 +288,34 @@
overflow-wrap: anywhere;
}
.receipt-ocr-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 12px;
}
.receipt-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.receipt-status-item {
grid-template-columns: minmax(90px, 1fr) auto;
align-items: center;
min-height: 30px;
}
.receipt-status-item strong {
min-height: 24px;
display: inline-flex;
align-items: center;
justify-self: start;
padding: 0 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.receipt-status-item .tone-success {
background: var(--success-soft);
color: var(--success-active);
}
.receipt-status-item .tone-warning {
background: #fff7ed;
color: #ea580c;
}
.receipt-status-item .tone-info {
background: #eff6ff;
color: #2563eb;
}
.receipt-other-info {
margin-top: 18px;
}
.receipt-other-collapse {
border-top: 1px solid #e5edf5;
border-bottom: 0;
}
.receipt-other-collapse :deep(.el-collapse-item__header) {
min-height: 42px;
height: auto;
border-bottom: 1px solid #e5edf5;
background: #fff;
color: #0f172a;
}
.receipt-other-collapse :deep(.el-collapse-item__wrap) {
border-bottom: 0;
}
.receipt-other-collapse :deep(.el-collapse-item__content) {
padding: 12px 0 0;
}
.receipt-collapse-title {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-right: 10px;
}
.receipt-collapse-title strong {
color: #0f172a;
font-size: 15px;
}
.receipt-collapse-title small {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.receipt-other-scroll {
max-height: 320px;
.receipt-all-field-grid {
max-height: clamp(360px, 60vh, 640px);
display: grid;
gap: 10px;
overflow-y: auto;
padding-right: 4px;
overflow-y: auto;
}
.receipt-edit-field-row {
display: grid;
grid-template-columns: minmax(120px, .72fr) minmax(180px, 1.28fr);
gap: 10px;
padding: 10px;
border: 1px solid #e1e8f0;
.receipt-all-field-grid.editing {
max-height: clamp(420px, 64vh, 680px);
}
.receipt-ocr-field {
padding: 8px 10px;
border: 1px solid #e5edf5;
border-radius: 4px;
background: #f8fafc;
}
.receipt-ocr-field strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 780;
line-height: 1.45;
overflow-wrap: anywhere;
}
.receipt-field-empty {
min-height: 64px;
display: inline-flex;
@@ -445,21 +331,25 @@
}
.receipt-preview-panel {
align-self: start;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: 14px;
gap: 12px;
}
.receipt-preview-frame {
min-width: 0;
min-height: 0;
padding: 10px;
border: 1px solid #e5edf5;
border-radius: 4px;
background: #fff;
overflow: hidden;
}
.receipt-preview-box {
min-height: 340px;
width: 100%;
height: clamp(380px, 56vh, 640px);
min-height: 0;
display: grid;
place-items: center;
overflow: auto;
@@ -467,8 +357,8 @@
}
.receipt-preview-box img {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
transform-origin: center center;
transition: transform 180ms ease;
@@ -477,6 +367,7 @@
.receipt-preview-box iframe {
width: 100%;
height: 100%;
min-height: 380px;
border: 0;
background: #fff;
}
@@ -495,10 +386,12 @@
}
.receipt-preview-tools {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
padding-top: 12px;
}
@@ -546,59 +439,107 @@
font-weight: 800;
}
.receipt-log-list {
position: relative;
.receipt-edit-log-section {
display: grid;
gap: 10px;
margin: 0;
padding: 0 0 0 16px;
list-style: none;
padding-top: 12px;
border-top: 1px solid #edf2f7;
}
.receipt-log-list::before {
content: "";
position: absolute;
left: 4px;
top: 6px;
bottom: 6px;
width: 1px;
background: #dbe4ee;
.receipt-edit-log-section header,
.receipt-edit-log-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.receipt-log-list li {
position: relative;
.receipt-edit-log-section header strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.receipt-edit-log-section header span,
.receipt-edit-log-meta span {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.receipt-edit-log-list {
display: grid;
grid-template-columns: 120px 54px minmax(0, 1fr);
max-height: 180px;
gap: 8px;
align-items: start;
margin: 0;
padding: 0 4px 0 0;
list-style: none;
overflow-y: auto;
}
.receipt-edit-log-list li {
display: grid;
gap: 7px;
padding: 9px 10px;
border: 1px solid #e5edf5;
border-radius: 4px;
background: #f8fafc;
}
.receipt-edit-log-meta strong {
color: #0f172a;
font-size: 12px;
font-weight: 800;
}
.receipt-edit-log-list p {
margin: 0;
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
color: #334155;
font-size: 12px;
}
.receipt-log-list li::before {
content: "";
position: absolute;
left: -15px;
top: 5px;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--theme-primary);
}
.receipt-log-list span {
.receipt-edit-log-list p span {
color: #64748b;
font-variant-numeric: tabular-nums;
font-weight: 750;
}
.receipt-log-list strong {
.receipt-edit-log-list p em {
max-width: 160px;
font-style: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receipt-edit-log-list p strong {
max-width: 180px;
color: #0f172a;
font-weight: 780;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receipt-log-list p {
margin: 0;
line-height: 1.45;
.receipt-edit-log-empty {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #d7e0ea;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.receipt-data-list.association {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.associate-step {
@@ -662,16 +603,9 @@
gap: 3px;
}
@media (max-width: 1120px) {
.receipt-dashboard,
.receipt-dashboard-bottom,
@media (max-width: 1180px) {
.receipt-folder-detail :deep(.detail-grid) {
grid-template-columns: 1fr;
overflow-y: auto;
}
.receipt-preview-panel {
min-height: 520px;
grid-template-columns: minmax(0, 1fr);
}
}
@@ -711,120 +645,32 @@
width: 100%;
}
.receipt-folder-list .table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.receipt-folder-list .table-wrap table,
.receipt-folder-list .table-wrap thead,
.receipt-folder-list .table-wrap tbody,
.receipt-folder-list .table-wrap tr,
.receipt-folder-list .table-wrap th,
.receipt-folder-list .table-wrap td {
display: block;
}
.receipt-folder-list .table-wrap table {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.receipt-folder-list .table-wrap thead,
.receipt-folder-list .table-wrap colgroup {
display: none;
}
.receipt-folder-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.receipt-folder-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.receipt-folder-list .table-wrap td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.receipt-folder-list .table-wrap td:last-child {
border-bottom: 0;
}
.receipt-folder-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.receipt-folder-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.receipt-folder-list .table-wrap td:first-child::before {
display: none;
}
.receipt-folder-list td:first-child .doc-id {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.receipt-folder-list .list-foot {
display: grid;
justify-items: stretch;
}
.receipt-folder-list .pager {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
}
.receipt-detail-toolbar,
.receipt-toolbar-actions,
.receipt-preview-tools {
align-items: stretch;
flex-direction: column;
}
.receipt-key-grid,
.receipt-edit-field-row,
.receipt-preview-tools > *,
.preview-tool-group {
width: 100%;
}
.preview-tool-group {
justify-content: center;
}
.receipt-static-grid,
.receipt-ocr-grid,
.receipt-status-grid {
.receipt-data-list.association {
grid-template-columns: 1fr;
}
.receipt-log-list li {
grid-template-columns: 1fr;
.receipt-preview-box {
height: clamp(320px, 60vh, 520px);
}
}

View File

@@ -865,6 +865,25 @@
overflow-x: auto;
}
.expense-recognition-banner {
min-width: 760px;
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid rgba(var(--theme-primary-rgb), .20);
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
}
.expense-recognition-banner i {
font-size: 16px;
}
.detail-expense-table table {
width: 100%;
min-width: 0;
@@ -907,12 +926,13 @@
background: var(--success-soft);
}
.detail-expense-table .col-time { width: 11%; }
.detail-expense-table .col-filled-at { width: 15%; }
.detail-expense-table .col-type { width: 13%; }
.detail-expense-table .col-desc { width: 19%; }
.detail-expense-table .col-amount { width: 11%; }
.detail-expense-table .col-attachment { width: 22%; }
.detail-expense-table .col-time { width: 10%; }
.detail-expense-table .col-filled-at { width: 13%; }
.detail-expense-table .col-type { width: 11%; }
.detail-expense-table .col-desc { width: 15%; }
.detail-expense-table .col-amount { width: 9%; }
.detail-expense-table .col-attachment { width: 18%; }
.detail-expense-table .col-risk-note { width: 15%; }
.detail-expense-table .col-action { width: 9%; }
.expense-time {
@@ -929,12 +949,25 @@
top: 50%;
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
display: inline-grid;
place-items: center;
transform: translateY(-50%);
color: #dc2626;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.expense-risk-indicator:hover {
color: #b91c1c;
}
.expense-risk-indicator:focus-visible {
outline: 2px solid rgba(220, 38, 38, .28);
outline-offset: 2px;
}
.cell-editor {
@@ -948,7 +981,8 @@
}
.editor-input,
.editor-select {
.editor-select,
.editor-textarea {
width: 100%;
min-height: 34px;
padding: 0 10px;
@@ -959,6 +993,13 @@
font-size: 12px;
}
.editor-textarea {
min-height: 68px;
padding: 8px 10px;
resize: vertical;
line-height: 1.45;
}
.currency-editor {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
@@ -979,7 +1020,8 @@
}
.editor-input:focus,
.editor-select:focus {
.editor-select:focus,
.editor-textarea:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
@@ -1036,6 +1078,29 @@
text-align: left;
}
.expense-risk-note strong {
display: block;
color: #0f172a;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
text-align: center;
overflow-wrap: anywhere;
}
.expense-risk-note span {
display: block;
color: #64748b;
font-size: 12px;
line-height: 1.45;
text-align: center;
}
.expense-risk-note .risk-note-missing {
color: #b45309;
font-weight: 750;
}
.over-tag {
display: inline-flex;
align-items: center;
@@ -1339,6 +1404,12 @@
font-weight: 700;
}
.system-attachment-note.pending {
border-color: rgba(var(--theme-primary-rgb), .20);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.empty-row-cell {
padding: 22px 16px;
color: #64748b;
@@ -1352,6 +1423,105 @@
display: none;
}
.smart-entry-upload-panel {
display: grid;
gap: 12px;
}
.smart-entry-upload-picker {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
border: 1px solid rgba(var(--theme-primary-rgb), .28);
border-radius: 4px;
background: #fff;
color: var(--theme-primary-active);
font-size: 13px;
font-weight: 850;
}
.smart-entry-upload-picker:hover {
background: var(--theme-primary-soft);
}
.smart-entry-upload-picker:disabled {
cursor: not-allowed;
opacity: .64;
}
.smart-entry-upload-file {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 68px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.smart-entry-upload-file > i {
color: var(--theme-primary-active);
font-size: 24px;
}
.smart-entry-upload-file strong,
.smart-entry-upload-file span {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.smart-entry-upload-file strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
white-space: nowrap;
}
.smart-entry-upload-file span {
margin-top: 3px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.smart-entry-upload-list {
display: grid;
gap: 2px;
max-height: 84px;
margin: 8px 0 0;
padding: 0;
overflow: auto;
list-style: none;
}
.smart-entry-upload-list li {
min-width: 0;
overflow: hidden;
color: #334155;
font-size: 12px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.smart-entry-upload-clear {
min-height: 30px;
padding: 0 10px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.attachment-preview-mask {
position: fixed;
inset: 0;
@@ -1813,6 +1983,30 @@
border-radius: 2px;
background: #ffffff;
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03);
transition: border-color .18s ease, box-shadow .18s ease, background .18s ease;
}
.validation-section--risk .risk-advice-card.is-highlighted {
border-color: #f59e0b;
background: #fff7ed;
box-shadow: 0 0 0 3px rgba(245, 158, 11, .20), 0 8px 18px rgba(15, 23, 42, .08);
animation: risk-card-flash 1.2s ease-in-out 1;
}
@keyframes risk-card-flash {
0%,
100% {
box-shadow: 0 0 0 3px rgba(245, 158, 11, .18), 0 8px 18px rgba(15, 23, 42, .08);
}
45% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, .30), 0 10px 22px rgba(15, 23, 42, .10);
}
}
@media (prefers-reduced-motion: reduce) {
.validation-section--risk .risk-advice-card.is-highlighted {
animation: none;
}
}
.validation-section--risk .risk-advice-card::before {

View File

@@ -1,7 +1,17 @@
<template>
<div class="trend-chart">
<div class="chart-legend">
<span><i :style="{ background: activeColor }"></i>{{ legendLabel }}</span>
<div class="chart-toolbar">
<div class="chart-legend">
<span
v-for="item in legendItems"
:key="item.name"
class="legend-pill"
:title="item.title"
>
<i :style="{ background: item.color }"></i>{{ item.name }}
</span>
</div>
<span class="chart-unit">{{ unitLabel }}</span>
</div>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
@@ -9,34 +19,59 @@
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import {
BarChart as EChartsBarChart,
CustomChart as EChartsCustomChart,
LineChart as EChartsLineChart
} from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsCustomChart, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
mode: { type: String, default: 'amount' },
claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] },
categoryAmountSeries: { type: Array, default: () => [] },
applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] },
avgHours: { type: Array, default: () => [] }
approved: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const isCountMode = computed(() => props.mode === 'count')
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue
blue: themeColors.value.chartBlue,
amber: themeColors.value.chartAmber,
purple: themeColors.value.chartPurple,
success: themeColors.value.success,
danger: themeColors.value.chartDanger
}))
const fallbackSeriesColors = computed(() => [
chartColors.value.blue,
chartColors.value.amber,
chartColors.value.purple,
chartColors.value.success,
chartColors.value.danger,
chartColors.value.primary
])
const expenseCategoryColorMap = computed(() => ({
'差旅': chartColors.value.blue,
'办公用品': chartColors.value.amber,
'业务招待': chartColors.value.purple,
'通讯': chartColors.value.success,
'培训': '#65789b',
'交通': chartColors.value.primary,
'餐饮': '#9a7b4f',
'会议': '#7f6c9f'
}))
const isCountMode = computed(() => props.mode === 'count')
const claimCountSeries = computed(() => (
props.claimCount.length ? props.claimCount : props.applications
))
@@ -46,22 +81,108 @@ const claimAmountSeries = computed(() => (
const activeSeries = computed(() => (
isCountMode.value ? claimCountSeries.value : claimAmountSeries.value
))
const amountCategorySeries = computed(() => {
if (isCountMode.value) {
return []
}
return (Array.isArray(props.categoryAmountSeries) ? props.categoryAmountSeries : [])
.filter((item) => Array.isArray(item.data) && item.data.some((value) => Number(value || 0) > 0))
.slice(0, 6)
})
const stackedAmountData = computed(() => props.labels.map((_, index) => [
index,
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
]))
const activeColor = computed(() => (
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
))
const legendLabel = computed(() => (
isCountMode.value ? '报销数量(单)' : '报销金额(元)'
isCountMode.value ? '报销数量' : '报销金额'
))
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
const legendItems = computed(() => {
if (amountCategorySeries.value.length) {
return amountCategorySeries.value.map((item, index) => ({
name: item.name || `费用类型 ${index + 1}`,
color: resolveCategoryColor(item, index),
title: `${item.name || `费用类型 ${index + 1}`} ${formatCurrency(item.total || 0)}`
}))
}
return [{
name: legendLabel.value,
color: activeColor.value,
title: `${legendLabel.value} ${unitLabel.value}`
}]
})
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
const stackedMaxValue = computed(() => {
if (!amountCategorySeries.value.length) {
return maxValue.value
}
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
return Math.max(...dailyTotals, 1)
})
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
isCountMode.value
? `${label}报销${claimCountSeries.value[index] || 0}`
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
)).join('')
)).join('')
)
const chartSeries = computed(() => {
if (!isCountMode.value && amountCategorySeries.value.length) {
return [{
name: '费用类型占比',
type: 'custom',
data: stackedAmountData.value,
renderItem: renderStackedAmountBar,
animationDelay: (index) => index * 18,
tooltip: {
formatter: (params) => formatStackedTooltip(params)
}
}]
}
return [{
name: legendLabel.value,
type: isCountMode.value ? 'line' : 'bar',
data: activeSeries.value,
barWidth: 16,
smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: activeColor.value
},
itemStyle: {
color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value,
borderWidth: isCountMode.value ? 2.5 : 0,
borderRadius: [4, 4, 0, 0]
},
areaStyle: {
opacity: isCountMode.value ? 1 : 0,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
{ offset: 1, color: toRgba(activeColor.value, 0.02) }
]
}
},
tooltip: {
valueFormatter: (value) => (
isCountMode.value ? `${Number(value || 0)}` : formatCurrency(value)
)
}
}]
})
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
@@ -70,7 +191,7 @@ const chartOptions = computed(() => ({
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 18,
top: 12,
right: 24,
bottom: 22,
left: 36,
@@ -89,7 +210,8 @@ const chartOptions = computed(() => ({
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => formatTooltip(params)
},
xAxis: {
type: 'category',
@@ -106,14 +228,9 @@ const chartOptions = computed(() => ({
yAxis: {
type: 'value',
min: 0,
max: Math.ceil(maxValue.value * 1.2),
max: Math.ceil(stackedMaxValue.value * 1.18),
splitNumber: 5,
name: isCountMode.value ? '单' : '元',
nameTextStyle: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
name: '',
axisLabel: {
color: '#64748b',
fontSize: 11,
@@ -122,46 +239,7 @@ const chartOptions = computed(() => ({
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
series: [
{
name: legendLabel.value,
type: isCountMode.value ? 'line' : 'bar',
data: activeSeries.value,
barWidth: 16,
smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: activeColor.value
},
itemStyle: {
color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value,
borderWidth: isCountMode.value ? 2.5 : 0,
borderRadius: [4, 4, 0, 0]
},
areaStyle: {
opacity: isCountMode.value ? 1 : 0,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
{ offset: 1, color: toRgba(activeColor.value, 0.02) }
]
}
},
tooltip: {
valueFormatter: (value) => (
isCountMode.value ? `${Number(value || 0)}` : formatCurrency(value)
)
}
}
]
series: chartSeries.value
}))
useEcharts(chartElement, chartOptions)
@@ -178,6 +256,134 @@ function toRgba(color, alpha) {
return `rgba(58, 124, 165, ${alpha})`
}
function resolveCategoryColor(item, index) {
const name = String(item?.name || '').trim()
const mapped = expenseCategoryColorMap.value[name]
if (mapped) {
return mapped
}
const fallback = fallbackSeriesColors.value[index % fallbackSeriesColors.value.length]
return resolveCssColor(item?.color, fallback)
}
function renderStackedAmountBar(params, api) {
const categoryIndex = Number(api.value(0))
const zeroPoint = api.coord([categoryIndex, 0])
const xCenter = zeroPoint[0]
const zeroY = zeroPoint[1]
const categoryWidth = api.size([1, 0])?.[0] || 32
const barWidth = Math.max(12, Math.min(24, categoryWidth * 0.48))
const barX = xCenter - barWidth / 2
let accumulated = 0
const values = amountCategorySeries.value.map((_, index) => Number(api.value(index + 1) || 0))
const lastVisibleIndex = values.reduce((last, value, index) => (value > 0 ? index : last), -1)
const children = []
let topY = zeroY
values.forEach((value, index) => {
if (value <= 0) {
return
}
const lower = accumulated
const upper = accumulated + value
const lowerY = api.coord([categoryIndex, lower])[1]
const upperY = api.coord([categoryIndex, upper])[1]
const height = Math.max(1, lowerY - upperY)
topY = Math.min(topY, upperY)
accumulated = upper
children.push({
type: 'rect',
shape: {
x: barX,
y: upperY,
width: barWidth,
height,
r: index === lastVisibleIndex ? [4, 4, 0, 0] : 0
},
style: {
fill: resolveCategoryColor(amountCategorySeries.value[index], index)
}
})
})
if (!children.length) {
return {
type: 'group',
children: []
}
}
const totalHeight = Math.max(1, zeroY - topY)
return {
type: 'group',
originX: xCenter,
originY: zeroY,
scaleY: 1,
enterFrom: {
scaleY: 0
},
transition: ['scaleY'],
clipPath: {
type: 'rect',
shape: {
x: barX,
y: topY,
width: barWidth,
height: totalHeight
},
enterFrom: {
shape: {
x: barX,
y: zeroY,
width: barWidth,
height: 0
}
},
transition: ['shape']
},
children
}
}
function formatTooltip(params) {
const items = Array.isArray(params) ? params : [params]
const first = items[0]
if (!first) {
return ''
}
if (!isCountMode.value && amountCategorySeries.value.length) {
return formatStackedTooltip(first)
}
const index = Number(first.dataIndex || 0)
const label = props.labels[index] || first.axisValueLabel || first.name || ''
const value = isCountMode.value ? claimCountSeries.value[index] : activeSeries.value[index]
const displayValue = isCountMode.value ? `${Number(value || 0)}` : formatCurrency(value)
return `${label}<br/>${legendLabel.value}${displayValue}`
}
function formatStackedTooltip(params) {
const index = Number(params?.data?.[0] ?? params?.dataIndex ?? 0)
const label = props.labels[index] || params?.axisValueLabel || ''
const rows = amountCategorySeries.value
.map((item, itemIndex) => ({
name: item.name || `费用类型 ${itemIndex + 1}`,
color: resolveCategoryColor(item, itemIndex),
value: Number(item.data?.[index] || 0)
}))
.filter((item) => item.value > 0)
const total = rows.reduce((sum, item) => sum + item.value, 0)
const details = rows.map((item) => (
`<span style="display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;background:${item.color};"></span>${item.name}${formatCurrency(item.value)}`
))
return [
label,
...details,
`合计:${formatCurrency(total)}`
].join('<br/>')
}
function formatCurrency(value) {
const number = Number(value || 0)
if (number >= 1000000) return `¥${(number / 1000000).toFixed(1)}M`
@@ -200,24 +406,61 @@ function formatAxisCurrency(value) {
flex-direction: column;
}
.chart-toolbar {
min-height: 30px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.chart-legend {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
gap: 6px 12px;
color: #475569;
font-size: 12px;
margin-bottom: 12px;
line-height: 1.4;
}
.legend-pill {
max-width: 132px;
display: inline-flex;
align-items: center;
min-width: 0;
color: #475569;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-legend i {
flex: 0 0 auto;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 4px;
margin-right: 5px;
vertical-align: middle;
}
.chart-unit {
flex: 0 0 auto;
padding: 2px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.5;
}
.chart-body {
flex: 1;
min-height: 0;

View File

@@ -37,6 +37,10 @@
<slot name="side"></slot>
</aside>
</div>
<section v-if="$slots.bottom" class="detail-bottom">
<slot name="bottom"></slot>
</section>
</template>
</div>

View File

@@ -51,6 +51,7 @@ const emptyFinanceTrend = {
labels: [],
claimCount: [],
claimAmount: [],
categoryAmountSeries: [],
applications: [],
approved: [],
avgHours: []
@@ -130,6 +131,9 @@ function resolveTopRangeKey(range, customRange = {}) {
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
return `recent-${resolveTopRangeDays(key, customRange)}-days`
}
if (/\d+/.test(key)) {
return `recent-${resolveTopRangeDays(key, customRange)}-days`
}
return key || DEFAULT_OVERVIEW_RANGE
}
@@ -155,7 +159,9 @@ export function useOverviewView(options = {}) {
const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null)
const financeDashboardRenderKey = ref(0)
const financeDashboardLoaded = computed(() => Boolean(financeDashboardPayload.value))
let financeDashboardRequestSeq = 0
const systemDashboardPayload = ref(null)
const systemDashboardLoading = ref(false)
const systemDashboardError = ref(null)
@@ -226,16 +232,27 @@ export function useOverviewView(options = {}) {
}
const loadFinanceDashboard = async () => {
const requestSeq = ++financeDashboardRequestSeq
financeDashboardLoading.value = true
financeDashboardError.value = null
try {
financeDashboardPayload.value = await fetchFinanceDashboard(getFinanceRangeParams())
const payload = await fetchFinanceDashboard(getFinanceRangeParams())
if (requestSeq !== financeDashboardRequestSeq) {
return
}
financeDashboardPayload.value = payload
financeDashboardRenderKey.value += 1
} catch (error) {
if (requestSeq !== financeDashboardRequestSeq) {
return
}
financeDashboardPayload.value = null
financeDashboardError.value = error
} finally {
financeDashboardLoading.value = false
if (requestSeq === financeDashboardRequestSeq) {
financeDashboardLoading.value = false
}
}
}
@@ -889,6 +906,7 @@ export function useOverviewView(options = {}) {
financeDashboardError,
financeDashboardLoaded,
financeDashboardLoading,
financeDashboardRenderKey,
formatCompact,
formatCurrency,
formatMetricValue,

View File

@@ -1,7 +1,7 @@
import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags } from '../utils/riskFlags.js'
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
@@ -429,13 +429,47 @@ function stringifyRiskFlag(value) {
return ''
}
function buildRiskSummary(riskFlags) {
const RISK_TONE_LABELS = {
high: '高风险',
medium: '中风险',
low: '低风险'
}
function resolveHighestRiskTone(flags) {
const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
if (tones.includes('high')) {
return 'high'
}
if (tones.includes('medium')) {
return 'medium'
}
if (tones.includes('low')) {
return 'low'
}
return 'low'
}
function buildRiskMeta(riskFlags) {
if (!Array.isArray(riskFlags) || !riskFlags.length) {
return '无'
return { summary: '无', tone: 'low', label: '无' }
}
const items = filterActionableRiskFlags(riskFlags).map((item) => stringifyRiskFlag(item)).filter(Boolean)
return items.length ? items.join('') : '无'
const actionableFlags = filterActionableRiskFlags(riskFlags)
const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
if (!items.length) {
return { summary: '无', tone: 'low', label: '无' }
}
const tone = resolveHighestRiskTone(actionableFlags)
return {
summary: items.join(''),
tone,
label: RISK_TONE_LABELS[tone] || '待关注'
}
}
function buildRiskSummary(riskFlags) {
return buildRiskMeta(riskFlags).summary
}
function buildOccurredDisplay(claim) {
@@ -1218,11 +1252,19 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
})
}
function buildExpenseItems(claim, riskSummary) {
function buildExpenseItems(claim, riskMeta) {
if (!Array.isArray(claim?.items)) {
return []
}
const normalizedRiskMeta = typeof riskMeta === 'string'
? { summary: riskMeta, tone: riskMeta === '无' ? 'low' : 'medium', label: riskMeta === '无' ? '无' : '待关注' }
: {
summary: String(riskMeta?.summary || '无').trim() || '无',
tone: String(riskMeta?.tone || 'low').trim() || 'low',
label: String(riskMeta?.label || '').trim() || (String(riskMeta?.summary || '').trim() === '无' ? '无' : '待关注')
}
const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
const sortedItems = [...visibleItems].sort((left, right) => {
const leftType = normalizeExpenseType(left?.item_type)
@@ -1241,6 +1283,7 @@ function buildExpenseItems(claim, riskSummary) {
const itemTypeLabel = resolveTypeLabel(itemType)
const itemLocation = String(item?.item_location || '').trim()
const itemReason = String(item?.item_reason || '').trim()
const itemNote = String(item?.item_note || item?.itemNote || '').trim()
const itemAmount = parseNumber(item?.item_amount)
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
@@ -1252,6 +1295,7 @@ function buildExpenseItems(claim, riskSummary) {
itemType,
itemReason,
itemLocation,
itemNote,
itemAmount,
invoiceId,
isSystemGenerated,
@@ -1273,9 +1317,9 @@ function buildExpenseItems(claim, riskSummary) {
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
attachments,
riskLabel: riskSummary === '无' ? '无' : '待关注',
riskText: riskSummary === '无' ? '' : riskSummary,
riskTone: riskSummary === '无' ? 'low' : 'medium'
riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label,
riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary,
riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone
}
})
}
@@ -1288,9 +1332,10 @@ export function mapExpenseClaimToRequest(claim) {
const approvalMeta = resolveApprovalMeta(claim?.status)
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
const riskMeta = buildRiskMeta(claim?.risk_flags_json)
const riskSummary = riskMeta.summary
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
const expenseItems = buildExpenseItems(claim, riskSummary)
const expenseItems = buildExpenseItems(claim, riskMeta)
const visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0)
const amountValue = relatedApplication
? expenseItems.length
@@ -1340,6 +1385,8 @@ export function mapExpenseClaimToRequest(claim) {
updatedAt: claim?.updated_at || '',
amount: amountValue,
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
riskTone: riskMeta.tone,
riskLabel: riskMeta.label,
invoiceCount,
workflowNode,
approvalKey: approvalMeta.key,

View File

@@ -231,6 +231,7 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
@@ -252,6 +253,17 @@ const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
const DOCUMENT_CENTER_QUERY_KEYS = new Set([
'dc_page',
'dc_page_size',
'dc_scope',
'dc_status',
'dc_doc_type',
'dc_scene',
'dc_q',
'dc_start',
'dc_end'
])
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: {
@@ -296,11 +308,14 @@ const FILTER_CONFIG_BY_SCOPE = {
}
}
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
const pageSizeValues = pageSizeOptions.map((item) => item.value)
const documentTypeOptions = [
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
]
const route = useRoute()
const router = useRouter()
const props = defineProps({
filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false },
@@ -315,19 +330,91 @@ const emit = defineEmits([
'reload',
'summary-change'
])
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
const activeStatusTab = ref('全部')
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
const activeScene = ref(SCENE_ALL)
function readDocumentCenterQueryText(key) {
const value = route.query?.[key]
return String(Array.isArray(value) ? value[0] || '' : value || '').trim()
}
function readDocumentCenterQueryNumber(key, fallback) {
const parsed = Number(readDocumentCenterQueryText(key))
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback
}
function resolveInitialScopeTab() {
const queryScope = readDocumentCenterQueryText('dc_scope')
if (scopeTabs.includes(queryScope)) {
return queryScope
}
return readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs)
}
function resolveInitialStatusTab(scope) {
const queryStatus = readDocumentCenterQueryText('dc_status') || '全部'
const config = FILTER_CONFIG_BY_SCOPE[scope] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_ALL]
return config.statusTabs.includes(queryStatus) ? queryStatus : '全部'
}
function resolveInitialDocumentType() {
const queryType = readDocumentCenterQueryText('dc_doc_type')
return documentTypeOptions.some((item) => item.value === queryType)
? queryType
: DOCUMENT_TYPE_ALL
}
function resolveInitialPageSize() {
const queryPageSize = readDocumentCenterQueryNumber('dc_page_size', 20)
return pageSizeValues.includes(queryPageSize) ? queryPageSize : 20
}
function buildDocumentCenterRouteQuery() {
const nextQuery = {}
Object.entries(route.query || {}).forEach(([key, value]) => {
if (!DOCUMENT_CENTER_QUERY_KEYS.has(key)) {
nextQuery[key] = value
}
})
if (currentPage.value > 1) nextQuery.dc_page = String(currentPage.value)
if (pageSize.value !== 20) nextQuery.dc_page_size = String(pageSize.value)
if (activeScopeTab.value !== DOCUMENT_SCOPE_ALL) nextQuery.dc_scope = activeScopeTab.value
if (activeStatusTab.value !== '全部') nextQuery.dc_status = activeStatusTab.value
if (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL) {
nextQuery.dc_doc_type = activeDocumentType.value
}
if (activeScene.value !== SCENE_ALL) nextQuery.dc_scene = activeScene.value
if (listKeyword.value.trim()) nextQuery.dc_q = listKeyword.value.trim()
if (appliedStart.value) nextQuery.dc_start = appliedStart.value
if (appliedEnd.value) nextQuery.dc_end = appliedEnd.value
return nextQuery
}
function routeQueryEquals(left, right) {
const leftEntries = Object.entries(left || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
const rightEntries = Object.entries(right || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
if (leftEntries.length !== rightEntries.length) return false
const rightMap = new Map(rightEntries)
return leftEntries.every(([key, value]) => rightMap.get(key) === value)
}
const initialScopeTab = resolveInitialScopeTab()
const initialAppliedStart = readDocumentCenterQueryText('dc_start')
const initialAppliedEnd = readDocumentCenterQueryText('dc_end')
const activeScopeTab = ref(initialScopeTab)
const activeStatusTab = ref(resolveInitialStatusTab(initialScopeTab))
const activeDocumentType = ref(resolveInitialDocumentType())
const activeScene = ref(readDocumentCenterQueryText('dc_scene') || SCENE_ALL)
const openFilterKey = ref('')
const listKeyword = ref('')
const listKeyword = ref(readDocumentCenterQueryText('dc_q'))
const datePopover = ref(false)
const rangeStart = ref('')
const rangeEnd = ref('')
const appliedStart = ref('')
const appliedEnd = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const rangeStart = ref(initialAppliedStart)
const rangeEnd = ref(initialAppliedEnd)
const appliedStart = ref(initialAppliedStart)
const appliedEnd = ref(initialAppliedEnd)
const currentPage = ref(readDocumentCenterQueryNumber('dc_page', 1))
const pageSize = ref(resolveInitialPageSize())
const archiveRows = ref([])
const approvalRows = ref([])
const supportingLoading = ref(false)
@@ -795,6 +882,20 @@ watch(
}
)
watch(
[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd],
() => {
if (route.name !== 'app-documents') {
return
}
const nextQuery = buildDocumentCenterRouteQuery()
if (!routeQueryEquals(route.query, nextQuery)) {
router.replace({ name: 'app-documents', query: nextQuery })
}
}
)
watch(activeFilterConfig, () => {
openFilterKey.value = ''
datePopover.value = false

View File

@@ -39,10 +39,12 @@
</div>
<TrendChart
:key="`finance-amount-${financeDashboardRenderKey}`"
mode="amount"
:labels="activeTrend.labels"
:claim-count="activeTrend.claimCount"
:claim-amount="activeTrend.claimAmount"
:category-amount-series="activeTrend.categoryAmountSeries"
/>
</article>
@@ -52,6 +54,7 @@
</div>
<TrendChart
:key="`finance-count-${financeDashboardRenderKey}`"
mode="count"
:labels="activeTrend.labels"
:claim-count="activeTrend.claimCount"
@@ -362,13 +365,12 @@ const {
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
digitalEmployeeDashboardError,
digitalEmployeeDashboardLoaded,
digitalEmployeeDashboardLoading,
digitalEmployeeDailyRows,
digitalEmployeeKpiMetrics,
digitalEmployeeTaskRanking,
financeDashboardLoading,
financeDashboardLoaded,
financeDashboardRenderKey,
kpiMetrics,
rankedDepartments,
rankedEmployees,
@@ -385,7 +387,6 @@ const {
spendCenterValue,
spendLegend,
systemDashboardLoading,
systemDashboardLoaded,
systemAccuracyComparison,
systemAgentDailyRatio,
systemFeedbackSummary,
@@ -413,15 +414,15 @@ const activeKpiMetrics = computed(() => {
})
const activeDashboardLoading = computed(() => {
if (activeDashboard.value === 'system') {
return systemDashboardLoading.value && !systemDashboardLoaded.value
return systemDashboardLoading.value
}
if (activeDashboard.value === 'digitalEmployee') {
return digitalEmployeeDashboardLoading.value && !digitalEmployeeDashboardLoaded.value
return digitalEmployeeDashboardLoading.value
}
if (activeDashboard.value === 'risk') {
return riskDashboardLoading.value && !riskDashboardLoaded.value
}
return financeDashboardLoading.value && !financeDashboardLoaded.value
return financeDashboardLoading.value
})
const activeDashboardLoadingText = computed(() => {
if (activeDashboard.value === 'system') return '正在加载系统看板数据'

View File

@@ -1,6 +1,6 @@
<template>
<section class="receipt-folder-page">
<article v-if="!detailMode" class="receipt-folder-list panel">
<article v-if="!detailMode" class="receipt-folder-list documents-list panel">
<nav class="status-tabs receipt-status-tabs" aria-label="票据关联状态">
<button
v-for="tab in receiptTabs"
@@ -134,36 +134,8 @@
loading-icon="mdi mdi-receipt-text-outline"
@back="backToList"
>
<section class="receipt-detail-toolbar panel">
<div class="receipt-detail-title">
<strong>票据详情</strong>
<span>{{ receiptDetailTitle }}</span>
<p>查看识别结果校验状态关联单据与处理记录</p>
</div>
<div class="receipt-toolbar-actions">
<button class="minor-action" type="button" @click="reloadCurrentReceipt">
<i class="mdi mdi-refresh"></i>
<span>重新读取</span>
</button>
<button
class="minor-action"
type="button"
:disabled="selectedReceipt?.status === 'linked'"
@click="openAssociateDialogForCurrentReceipt"
>
<i class="mdi mdi-link-variant-plus"></i>
<span>关联单据</span>
</button>
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
</button>
</div>
</section>
<section class="receipt-dashboard">
<EnterpriseDetailCard class="receipt-preview-panel receipt-dashboard-preview" title="票据预览">
<template #main>
<EnterpriseDetailCard class="receipt-preview-panel" title="票据预览">
<div class="receipt-preview-frame">
<div class="receipt-preview-box">
<img
@@ -172,7 +144,7 @@
:style="{ transform: previewTransform }"
alt="票据预览"
/>
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewFrameUrl" title="票据 PDF 预览"></iframe>
<div v-else class="preview-empty">
<i class="mdi mdi-file-eye-outline"></i>
<strong>当前文件暂不支持内嵌预览</strong>
@@ -201,115 +173,110 @@
</button>
</div>
</footer>
<section class="receipt-edit-log-section">
<header>
<strong>用户编辑操作</strong>
<span>{{ receiptEditLogs.length }} </span>
</header>
<ol v-if="receiptEditLogs.length" class="receipt-edit-log-list">
<li v-for="log in receiptEditLogs" :key="`${log.operated_at}-${log.operator}`">
<div class="receipt-edit-log-meta">
<strong>{{ log.operator || '当前用户' }}</strong>
<span>{{ formatDateTime(log.operated_at) }}</span>
</div>
<p v-for="change in log.changes" :key="`${change.key}-${change.before}-${change.after}`">
<span>{{ change.label || change.key }}</span>
<em>{{ change.before || '空' }}</em>
<i class="mdi mdi-arrow-right"></i>
<strong>{{ change.after || '空' }}</strong>
</p>
</li>
</ol>
<div v-else class="receipt-edit-log-empty">
<i class="mdi mdi-history"></i>
<span>暂无用户修改记录</span>
</div>
</section>
</EnterpriseDetailCard>
</template>
<div class="receipt-dashboard-side">
<EnterpriseDetailCard class="receipt-basic-panel" title="基础信息">
<template #actions>
<span class="receipt-card-count">{{ keyReceiptFields.length }} 项可编辑</span>
</template>
<template #side>
<EnterpriseDetailCard class="receipt-ticket-info-panel" title="识别票据详情">
<template #actions>
<div class="receipt-card-actions">
<button v-if="!receiptInfoEditing" class="minor-action" type="button" @click="startReceiptInfoEdit">
<i class="mdi mdi-pencil-outline"></i>
<span>编辑</span>
</button>
<template v-else>
<button class="minor-action" type="button" :disabled="savingDetail" @click="cancelReceiptInfoEdit">
<span>取消</span>
</button>
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ savingDetail ? '保存中' : '保存' }}</span>
</button>
</template>
</div>
</template>
<div class="receipt-key-grid">
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
<span>{{ field.label }}</span>
<input
:value="field.value"
type="text"
:placeholder="field.placeholder"
@input="updateReceiptField(field, $event.target.value)"
/>
</label>
<div class="receipt-static-grid">
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<section class="receipt-ticket-section">
<div class="receipt-section-head">
<strong>识别字段</strong>
<small>{{ detailForm.fields.length }} </small>
</div>
<div class="receipt-static-grid">
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</EnterpriseDetailCard>
<EnterpriseDetailCard class="receipt-ocr-panel" title="OCR识别结果">
<div v-if="ocrPreviewFields.length" class="receipt-ocr-grid">
<label v-for="field in ocrPreviewFields" :key="field.key || field.label" class="receipt-ocr-field">
<div v-if="detailForm.fields.length" class="receipt-all-field-grid" :class="{ editing: receiptInfoEditing }">
<label v-for="field in detailForm.fields" :key="field.key || field.label" class="receipt-ocr-field">
<span>{{ field.label || field.key }}</span>
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
<input
v-if="receiptInfoEditing"
v-model="field.value"
type="text"
placeholder="字段值"
@input="syncEditableFieldsToTopLevel"
/>
<strong v-else>{{ field.value || '待补全' }}</strong>
</label>
</div>
<div v-else class="receipt-field-empty">
<i class="mdi mdi-information-outline"></i>
<span>暂无可展示的 OCR 识别字段</span>
<span>暂无可展示的 OCR 识别字段</span>
</div>
</section>
</EnterpriseDetailCard>
</template>
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse">
<ElCollapseItem name="other">
<template #title>
<div class="receipt-collapse-title">
<strong>其他信息</strong>
<small>{{ editableOtherFields.length }} </small>
</div>
</template>
<template #bottom>
<EnterpriseDetailCard class="receipt-association-panel" title="关联信息">
<template #actions>
<button
v-if="selectedReceipt?.status !== 'linked'"
class="minor-action"
type="button"
@click="openAssociateDialogForCurrentReceipt"
>
<i class="mdi mdi-link-variant-plus"></i>
<span>关联单据</span>
</button>
</template>
<div v-if="editableOtherFields.length" class="receipt-other-scroll">
<div
v-for="(field, index) in editableOtherFields"
:key="`${field.key || field.label}-${index}`"
class="receipt-edit-field-row"
>
<label>
<span>字段名</span>
<input v-model="field.label" type="text" placeholder="字段名" />
</label>
<label>
<span>字段值</span>
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
</label>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</EnterpriseDetailCard>
<EnterpriseDetailCard class="receipt-status-panel" title="处理状态">
<div class="receipt-status-grid">
<div v-for="item in receiptStatusItems" :key="item.label" class="receipt-status-item">
<span>{{ item.label }}</span>
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
</div>
<div class="receipt-data-list association">
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</EnterpriseDetailCard>
</div>
<div class="receipt-dashboard-bottom">
<EnterpriseDetailCard class="receipt-info-panel" title="关联单据信息">
<div class="receipt-data-list">
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</EnterpriseDetailCard>
<EnterpriseDetailCard class="receipt-log-panel" title="处理记录 / 操作日志">
<ol class="receipt-log-list">
<li v-for="item in operationLogs" :key="`${item.time}-${item.label}`">
<span>{{ item.time }}</span>
<strong>{{ item.operator }}</strong>
<p>{{ item.label }}</p>
</li>
</ol>
</EnterpriseDetailCard>
<EnterpriseDetailCard class="receipt-info-panel" title="归档信息">
<div class="receipt-data-list">
<div v-for="item in archiveInfoItems" :key="item.label" class="receipt-data-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</EnterpriseDetailCard>
</div>
</section>
</div>
</EnterpriseDetailCard>
</template>
<template #actions>
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
@@ -380,7 +347,6 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
@@ -416,13 +382,13 @@ const detailLoading = ref(false)
const savingDetail = ref(false)
const deleting = ref(false)
const previewObjectUrl = ref('')
const receiptInfoEditing = ref(false)
const associateDialogOpen = ref(false)
const associateStep = ref(1)
const selectedReceiptIds = ref([])
const targetDraftId = ref(NEW_CLAIM_VALUE)
const draftClaims = ref([])
const associateBusy = ref(false)
const expandedFieldPanels = ref([])
const detailForm = reactive({
file_name: '',
@@ -514,28 +480,20 @@ const isTrainTicket = computed(() => {
})
const {
buildDetailPayload,
editableOtherFields,
ensureEditableReceiptFields,
keyReceiptFields,
syncEditableFieldsToTopLevel,
updateReceiptField
syncEditableFieldsToTopLevel
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
const {
adjustPreviewZoom,
archiveInfoItems,
basicInfoItems,
linkedClaimItems,
ocrPreviewFields,
operationLogs,
previewPageLabel,
previewTransform,
previewZoom,
receiptStatusItems,
resetPreviewView,
rotatePreview
} = createReceiptDetailDashboardModel({
detailForm,
editableOtherFields,
formatDateTime,
formatScore,
selectedReceipt
@@ -554,6 +512,15 @@ const receiptDetailTopBarPayload = computed(() => (
: null
))
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '')
const previewFrameUrl = computed(() => (
previewKind.value === 'pdf' && previewObjectUrl.value
? `${previewObjectUrl.value}#toolbar=0&navpanes=0&view=Fit`
: previewObjectUrl.value
))
const receiptEditLogs = computed(() => {
const logs = selectedReceipt.value?.edit_logs || selectedReceipt.value?.editLogs || []
return Array.isArray(logs) ? logs : []
})
const canProceedAssociate = computed(() => (
associateStep.value === 1
? selectedReceiptIds.value.length > 0
@@ -635,7 +602,7 @@ function fillDetailForm(detail) {
detailForm.fields = Array.isArray(detail.fields)
? detail.fields.map((field) => ({ ...field }))
: []
expandedFieldPanels.value = []
receiptInfoEditing.value = false
resetPreviewView()
ensureEditableReceiptFields()
syncEditableFieldsToTopLevel()
@@ -660,6 +627,7 @@ function revokePreviewUrl() {
function backToList() {
selectedReceipt.value = null
receiptInfoEditing.value = false
revokePreviewUrl()
}
@@ -668,6 +636,17 @@ async function reloadCurrentReceipt() {
await openDetail(selectedReceipt.value)
}
function startReceiptInfoEdit() {
receiptInfoEditing.value = true
}
function cancelReceiptInfoEdit() {
if (selectedReceipt.value) {
fillDetailForm(selectedReceipt.value)
}
receiptInfoEditing.value = false
}
async function saveDetail() {
if (!selectedReceipt.value?.id || savingDetail.value) return
savingDetail.value = true
@@ -776,6 +755,7 @@ function formatScore(value) {
}
function formatDateTime(value) {
if (!String(value ?? '').trim()) return '待确认'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '待确认'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`

View File

@@ -126,9 +126,9 @@
</p>
</div>
<div v-if="!isApplicationDocument" class="detail-card-actions">
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" :disabled="actionBusy" @click="triggerSmartEntryUpload">
<i class="mdi mdi-robot-outline"></i>
<span>智能录入</span>
<span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
</button>
<button
v-if="isEditableRequest"
@@ -190,6 +190,10 @@
</div>
</div>
<div v-if="!isApplicationDocument" class="detail-expense-table">
<div v-if="smartEntryRecognitionBusy" class="expense-recognition-banner">
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ smartEntryRecognitionText }}</span>
</div>
<table>
<thead>
<tr>
@@ -199,6 +203,7 @@
<th class="col-desc">说明</th>
<th class="col-amount">金额</th>
<th class="col-attachment">附件材料</th>
<th class="col-risk-note">异常说明</th>
<th v-if="isEditableRequest" class="col-action">操作</th>
</tr>
</thead>
@@ -209,13 +214,17 @@
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
<i
v-if="isMajorExpenseRisk(item)"
class="mdi mdi-alert expense-risk-indicator"
<td :class="['expense-time col-time', { 'has-major-risk': hasExpenseRiskIndicator(item) }]">
<button
v-if="hasExpenseRiskIndicator(item)"
class="expense-risk-indicator"
type="button"
:title="resolveExpenseRiskIndicatorTitle(item)"
:aria-label="resolveExpenseRiskIndicatorTitle(item)"
></i>
@click="focusExpenseRisk(item)"
>
<i class="mdi mdi-alert"></i>
</button>
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
@@ -281,6 +290,10 @@
<td class="expense-attachment col-attachment">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor editor-stack">
<div v-if="uploadingExpenseId === item.id" class="system-attachment-note pending">
<i class="mdi mdi-loading mdi-spin"></i>
<span>识别中</span>
</div>
<div class="attachment-action-group">
<button
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
@@ -318,7 +331,11 @@
</div>
</template>
<template v-else>
<div v-if="item.isSystemGenerated" class="system-attachment-note">
<div v-if="uploadingExpenseId === item.id" class="system-attachment-note pending">
<i class="mdi mdi-loading mdi-spin"></i>
<span>识别中</span>
</div>
<div v-else-if="item.isSystemGenerated" class="system-attachment-note">
<i class="mdi mdi-calculator-variant-outline"></i>
<span>无需附件</span>
</div>
@@ -358,6 +375,24 @@
</div>
</template>
</td>
<td class="expense-risk-note col-risk-note">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<textarea
v-model="expenseEditor.itemNote"
class="editor-textarea"
rows="3"
placeholder="如票据存在异常或风险,请补充原因"
></textarea>
<span>用于说明改签绕行超标票据异常等情况</span>
</div>
</template>
<template v-else>
<strong v-if="item.itemNote">{{ item.itemNote }}</strong>
<span v-else-if="hasExpenseRiskOrAbnormal(item)" class="risk-note-missing">待补充异常说明</span>
<span v-else>无异常说明</span>
</template>
</td>
<td v-if="isEditableRequest" class="expense-action-cell col-action">
<div v-if="item.isSystemGenerated" class="system-row-lock">
<i class="mdi mdi-lock-outline"></i>
@@ -438,7 +473,9 @@
<article
v-for="card in section.items"
:key="card.id"
:class="['risk-advice-card', card.tone]"
:id="resolveRiskCardDomId(card)"
:data-risk-card-id="card.id"
:class="['risk-advice-card', card.tone, { 'is-highlighted': isHighlightedRiskCard(card) }]"
>
<div class="risk-advice-card-main">
<div class="risk-advice-card-head">
@@ -545,6 +582,58 @@
accept="image/*,.pdf"
@change="handleExpenseFileChange"
/>
<input
ref="smartEntryUploadInput"
class="expense-upload-input"
type="file"
accept="image/*,.pdf"
multiple
@change="handleSmartEntryFileChange"
/>
<ConfirmDialog
:open="smartEntryUploadDialogOpen"
badge="智能录入"
title="上传报销附件"
description="请选择需要识别并归集到当前草稿的票据附件,确认前可以清除或重新选择。"
cancel-text="取消"
confirm-text="确认识别"
busy-text="识别中"
confirm-icon="mdi mdi-file-search-outline"
:busy="smartEntryUploadBusy"
@close="closeSmartEntryUploadDialog"
@confirm="confirmSmartEntryUpload"
>
<div class="smart-entry-upload-panel">
<button
class="smart-entry-upload-picker"
type="button"
:disabled="smartEntryUploadBusy"
@click="chooseSmartEntryFile"
>
<i class="mdi mdi-tray-arrow-up"></i>
<span>{{ smartEntrySelectedFileCount ? '重新选择附件' : '选择附件' }}</span>
</button>
<div class="smart-entry-upload-file">
<i :class="smartEntrySelectedFileCount ? 'mdi mdi-file-check-outline' : 'mdi mdi-file-outline'"></i>
<div>
<strong>{{ smartEntrySelectedFileSummary || '尚未选择附件' }}</strong>
<span>支持 JPGPNGPDF确认后系统会逐张识别并归集到草稿明细</span>
<ul v-if="smartEntrySelectedFileNames.length" class="smart-entry-upload-list">
<li v-for="fileName in smartEntrySelectedFileNames" :key="fileName">{{ fileName }}</li>
</ul>
</div>
<button
v-if="smartEntrySelectedFileCount"
class="smart-entry-upload-clear"
type="button"
:disabled="smartEntryUploadBusy"
@click="clearSmartEntryFile"
>
清除
</button>
</div>
</div>
</ConfirmDialog>
<Transition name="shared-confirm">
<div
v-if="attachmentPreviewOpen"

View File

@@ -1606,6 +1606,24 @@ export default {
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === 'open_receipt_folder') {
if (!lockSuggestedActionMessage(message, action)) return
await router.push({ name: 'app-receiptFolder' })
emit('close')
return
}
if (actionType === 'continue_upload_with_unlinked_receipts') {
if (!lockSuggestedActionMessage(message, action)) return
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
await submitComposer({
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
files: Array.from(attachedFiles.value || []),
skipReceiptFolderUnlinkedPrompt: true
})
return
}
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null

View File

@@ -1,4 +1,4 @@
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -94,6 +94,223 @@ import {
} from './travelRequestDetailAdviceModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
const SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS = 10 * 60 * 1000
const smartEntryRecognitionTasks = new Map()
let smartEntryRecognitionTaskSeq = 0
function normalizeSmartEntryClaimId(claimId) {
return String(claimId || '').trim()
}
function buildRecognizedExpenseItemPatch(payload, fileName = '') {
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || fileName || '').trim()
}
if (recognizedItemDate) {
itemPatch.itemDate = recognizedItemDate
}
if (recognizedItemType) {
itemPatch.itemType = recognizedItemType
}
if (recognizedItemReason) {
itemPatch.itemReason = recognizedItemReason
}
if (recognizedItemLocation) {
itemPatch.itemLocation = recognizedItemLocation
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
return itemPatch
}
function buildSmartEntryRecognitionSnapshot(task) {
if (!task) {
return null
}
return {
id: task.id,
claimId: task.claimId,
busy: task.busy,
total: task.total,
current: task.current,
completed: task.completed,
successCount: task.successCount,
failedCount: task.failedCount,
uploadingItemId: task.uploadingItemId,
fileName: task.fileName,
status: task.status,
payloads: [...task.payloads],
errors: [...task.errors]
}
}
function notifySmartEntryRecognitionTask(task) {
const snapshot = buildSmartEntryRecognitionSnapshot(task)
task.listeners.forEach((listener) => {
try {
listener(snapshot)
} catch (error) {
console.error('同步附件识别状态失败', error)
}
})
}
function scheduleSmartEntryRecognitionTaskCleanup(task) {
if (task.cleanupTimer) {
clearTimeout(task.cleanupTimer)
}
task.cleanupTimer = globalThis.setTimeout(() => {
const currentTask = smartEntryRecognitionTasks.get(task.claimId)
if (currentTask?.id === task.id && !currentTask.busy) {
smartEntryRecognitionTasks.delete(task.claimId)
}
}, SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS)
}
function getSmartEntryRecognitionTask(claimId) {
return smartEntryRecognitionTasks.get(normalizeSmartEntryClaimId(claimId)) || null
}
function subscribeSmartEntryRecognitionTask(claimId, listener) {
const task = getSmartEntryRecognitionTask(claimId)
if (!task) {
listener(null)
return () => {}
}
task.listeners.add(listener)
listener(buildSmartEntryRecognitionSnapshot(task))
return () => {
task.listeners.delete(listener)
}
}
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
.map((item) => ({ id: String(item.id || '').trim() }))
.filter((item) => item.id)
}
async function resolveSmartEntryRecognitionTaskItem(task) {
const availableItem = task.availableItems.shift()
if (availableItem?.id) {
return { id: availableItem.id, createdItem: null }
}
const claim = await createExpenseClaimItem(task.claimId, {})
const items = Array.isArray(claim?.items) ? claim.items : []
const createdItem = items.find((entry) => {
const itemId = String(entry?.id || '').trim()
return itemId && !task.knownItemIds.has(itemId)
})
if (!createdItem) {
throw new Error('新增费用明细失败,请稍后重试。')
}
const itemId = String(createdItem.id || '').trim()
task.knownItemIds.add(itemId)
return { id: itemId, createdItem }
}
async function runSmartEntryRecognitionTask(task, files) {
notifySmartEntryRecognitionTask(task)
for (let index = 0; index < files.length; index += 1) {
const file = files[index]
const fileName = String(file?.name || `${index + 1} 张附件`).trim()
task.current = index + 1
task.fileName = fileName
task.uploadingItemId = ''
notifySmartEntryRecognitionTask(task)
try {
const targetItem = await resolveSmartEntryRecognitionTaskItem(task)
task.uploadingItemId = targetItem.id
notifySmartEntryRecognitionTask(task)
const payload = await uploadExpenseClaimItemAttachment(task.claimId, targetItem.id, file)
task.successCount += 1
task.payloads.push({
id: `${task.id}:${index}:${targetItem.id}`,
itemId: targetItem.id,
fileName,
payload,
createdItem: targetItem.createdItem
})
} catch (error) {
task.failedCount += 1
task.errors.push({
fileName,
message: error?.message || '附件识别失败,请稍后重试。'
})
} finally {
task.completed = index + 1
task.uploadingItemId = ''
notifySmartEntryRecognitionTask(task)
}
}
task.busy = false
task.current = task.total
task.fileName = ''
task.status = task.failedCount
? task.successCount
? 'partial'
: 'failed'
: 'completed'
notifySmartEntryRecognitionTask(task)
scheduleSmartEntryRecognitionTaskCleanup(task)
}
function startSmartEntryRecognitionTask({ claimId, files, itemSnapshots }) {
const normalizedClaimId = normalizeSmartEntryClaimId(claimId)
const pendingFiles = Array.isArray(files) ? files.filter(Boolean) : []
if (!normalizedClaimId || !pendingFiles.length) {
return { task: null, reused: false }
}
const existingTask = getSmartEntryRecognitionTask(normalizedClaimId)
if (existingTask?.busy) {
return { task: existingTask, reused: true }
}
const sourceItems = Array.isArray(itemSnapshots) ? itemSnapshots : []
const task = {
id: `smart-entry-${Date.now()}-${smartEntryRecognitionTaskSeq += 1}`,
claimId: normalizedClaimId,
busy: true,
total: pendingFiles.length,
current: 0,
completed: 0,
successCount: 0,
failedCount: 0,
uploadingItemId: '',
fileName: '',
status: 'running',
payloads: [],
errors: [],
availableItems: resolveSmartEntryTaskAvailableItems(sourceItems),
knownItemIds: new Set(sourceItems.map((item) => String(item?.id || '').trim()).filter(Boolean)),
listeners: new Set(),
cleanupTimer: null
}
smartEntryRecognitionTasks.set(normalizedClaimId, task)
void runSmartEntryRecognitionTask(task, pendingFiles)
return { task, reused: false }
}
/*
* 以下片段仅用于兼容现有源码正则测试。
* 运行时实现位于 travelRequestDetailExpenseModel.js。
@@ -388,6 +605,8 @@ export default {
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
const riskOverrideIndex = ref(0)
const highlightedRiskCardId = ref('')
let highlightedRiskCardTimer = 0
const riskOverrideReasons = reactive({})
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
@@ -397,6 +616,16 @@ export default {
const approveConfirmDialogOpen = ref(false)
const leaderOpinion = ref('')
const expenseUploadInput = ref(null)
const smartEntryUploadInput = ref(null)
const smartEntryUploadDialogOpen = ref(false)
const smartEntrySelectedFiles = ref([])
const smartEntryRecognitionBusy = ref(false)
const smartEntryRecognitionTotal = ref(0)
const smartEntryRecognitionCompleted = ref(0)
const smartEntryRecognitionCurrent = ref(0)
const appliedSmartEntryRecognitionPayloadIds = new Set()
const notifiedSmartEntryRecognitionTaskIds = new Set()
let stopSmartEntryRecognitionTask = null
const expenseAttachmentMeta = reactive({})
const attachmentPreviewOpen = ref(false)
const attachmentPreviewLoading = ref(false)
@@ -411,6 +640,7 @@ export default {
itemReason: '',
itemLocation: '',
itemAmount: '',
itemNote: '',
invoiceId: ''
})
const detailNoteEditor = ref('')
@@ -669,6 +899,7 @@ export default {
|| approveBusy.value
|| payBusy.value
|| creatingExpense.value
|| smartEntryRecognitionBusy.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
|| Boolean(deletingExpenseId.value)
@@ -773,7 +1004,7 @@ export default {
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseTableColumnCount = computed(
() => 6 + (isEditableRequest.value ? 1 : 0)
() => 7 + (isEditableRequest.value ? 1 : 0)
)
const canEditDetailNote = computed(() => isDraftRequest.value)
const stripDetailNoteRiskTags = (value) =>
@@ -821,12 +1052,42 @@ export default {
() => request.value.claimId,
() => {
riskFlagPreviewSnapshot.value = null
}
appliedSmartEntryRecognitionPayloadIds.clear()
bindSmartEntryRecognitionTask()
},
{ immediate: true }
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
const smartEntryRecognitionText = computed(() => {
const total = smartEntryRecognitionTotal.value
if (!total) {
return '附件识别准备中,请稍候。识别完成前暂不可编辑费用明细。'
}
const current = Math.min(Math.max(smartEntryRecognitionCurrent.value || 1, 1), total)
return `附件识别中(${current}/${total}),请稍候。识别完成前暂不可编辑费用明细。`
})
const smartEntrySelectedFileCount = computed(() => smartEntrySelectedFiles.value.length)
const smartEntrySelectedFileNames = computed(() =>
smartEntrySelectedFiles.value
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
)
const smartEntrySelectedFileSummary = computed(() => {
const names = smartEntrySelectedFileNames.value
if (!names.length) {
return ''
}
if (names.length === 1) {
return names[0]
}
return `已选择 ${names.length} 张附件`
})
const smartEntryUploadBusy = computed(() =>
smartEntryUploadDialogOpen.value && (creatingExpense.value || Boolean(uploadingExpenseId.value))
)
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => canPreviewAttachment(item))
@@ -929,6 +1190,102 @@ export default {
return `${label}${summary}`
}
function resetSmartEntryRecognitionState() {
smartEntryRecognitionBusy.value = false
smartEntryRecognitionTotal.value = 0
smartEntryRecognitionCompleted.value = 0
smartEntryRecognitionCurrent.value = 0
if (!pendingUploadExpenseId.value) {
uploadingExpenseId.value = ''
}
}
function ensureSmartEntryRecognitionItem(entry, patch) {
const itemId = String(entry?.itemId || '').trim()
if (!itemId) {
return null
}
const existingItem = expenseItems.value.find((item) => item.id === itemId)
if (existingItem) {
return existingItem
}
const rawItem = entry?.createdItem || {
id: itemId,
invoice_id: patch.invoiceId,
item_date: patch.itemDate,
item_type: patch.itemType,
item_reason: patch.itemReason,
item_location: patch.itemLocation,
item_amount: patch.itemAmount,
attachment_hint: patch.attachmentHint
}
const nextItem = buildExpenseItemViewModel(rawItem, expenseItems.value.length, request.value)
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
return nextItem
}
function applySmartEntryRecognitionPayload(entry) {
const payloadId = String(entry?.id || '').trim()
const itemId = String(entry?.itemId || '').trim()
if (!payloadId || !itemId || appliedSmartEntryRecognitionPayloadIds.has(payloadId)) {
return
}
const itemPatch = buildRecognizedExpenseItemPatch(entry.payload, entry.fileName)
const item = ensureSmartEntryRecognitionItem(entry, itemPatch)
if (!item) {
return
}
applyClaimRiskFlagsPayload(entry.payload)
if (entry.payload?.attachment) {
expenseAttachmentMeta[itemId] = entry.payload.attachment
}
applyLocalExpenseItemPatch(itemId, itemPatch)
if (editingExpenseId.value === itemId) {
populateExpenseEditor({ ...item, ...itemPatch })
}
appliedSmartEntryRecognitionPayloadIds.add(payloadId)
emit('request-updated', { claimId: request.value.claimId })
}
function syncSmartEntryRecognitionSnapshot(snapshot) {
if (!snapshot) {
resetSmartEntryRecognitionState()
return
}
smartEntryRecognitionBusy.value = Boolean(snapshot.busy)
smartEntryRecognitionTotal.value = snapshot.total || 0
smartEntryRecognitionCompleted.value = snapshot.completed || 0
smartEntryRecognitionCurrent.value = snapshot.current || 0
uploadingExpenseId.value = snapshot.uploadingItemId || ''
snapshot.payloads.forEach((entry) => applySmartEntryRecognitionPayload(entry))
if (!snapshot.busy && snapshot.status && !notifiedSmartEntryRecognitionTaskIds.has(snapshot.id)) {
notifiedSmartEntryRecognitionTaskIds.add(snapshot.id)
if (snapshot.failedCount && snapshot.successCount) {
toast(`已完成 ${snapshot.successCount} 张附件识别,${snapshot.failedCount} 张识别失败。`)
} else if (snapshot.failedCount) {
toast('附件识别失败,请稍后重试。')
} else if (snapshot.total > 1) {
toast(`已完成 ${snapshot.successCount} 张附件的智能录入。`)
}
}
}
function bindSmartEntryRecognitionTask(claimId = request.value.claimId) {
if (stopSmartEntryRecognitionTask) {
stopSmartEntryRecognitionTask()
stopSmartEntryRecognitionTask = null
}
stopSmartEntryRecognitionTask = subscribeSmartEntryRecognitionTask(claimId, syncSmartEntryRecognitionSnapshot)
}
async function refreshExpenseAttachmentMeta(itemId) {
if (!request.value.claimId || !itemId) {
return null
@@ -1048,10 +1405,19 @@ export default {
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
}
function hasExpenseRiskOrAbnormal(item) {
const state = resolveExpenseRiskState(item)
return Boolean(
String(item?.itemNote || '').trim()
|| normalizeRiskTone(state?.tone) !== 'low'
|| item?.tone === 'bad'
)
}
function resolveExpenseRiskIndicatorTitle(item) {
const state = resolveExpenseRiskState(item)
const summary = String(state?.summary || state?.headline || '').trim()
return summary ? `重大风险示:${summary}` : '重大风险示'
return summary ? `查看风险示:${summary}` : '查看风险示'
}
function applyClaimRiskFlagsPayload(payload) {
@@ -1198,6 +1564,62 @@ export default {
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
))
function normalizeRiskDomId(value) {
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '-') || 'unknown'
}
function resolveRiskCardDomId(card) {
return `detail-risk-card-${normalizeRiskDomId(card?.id)}`
}
function isHighlightedRiskCard(card) {
return Boolean(card?.id) && String(card.id) === highlightedRiskCardId.value
}
function resolveExpenseRiskTargetCard(item) {
const itemId = String(item?.id || '').trim()
const invoiceId = String(item?.invoiceId || '').trim()
const itemIndex = expenseItems.value.findIndex((entry) => entry.id === item?.id) + 1
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId)
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`${itemIndex}`))
|| null
}
function hasExpenseRiskIndicator(item) {
return Boolean(resolveExpenseRiskTargetCard(item))
}
async function focusExpenseRisk(item) {
const card = resolveExpenseRiskTargetCard(item)
const riskSection = document.querySelector('.validation-section--risk')
if (!card && !riskSection) {
toast('当前费用明细暂无可定位的风险点。')
return
}
highlightedRiskCardId.value = card?.id ? String(card.id) : ''
await nextTick()
const target = card
? document.getElementById(resolveRiskCardDomId(card))
: riskSection
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer)
}
highlightedRiskCardTimer = window.setTimeout(() => {
highlightedRiskCardId.value = ''
highlightedRiskCardTimer = 0
}, 1800)
}
const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
return '报销风险提示'
@@ -1375,6 +1797,7 @@ export default {
expenseEditor.itemLocation =
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.itemNote = item.itemNote || ''
expenseEditor.invoiceId = item.invoiceId || ''
}
@@ -1416,14 +1839,10 @@ export default {
return ''
}
async function handleAddExpenseItem() {
if (!isEditableRequest.value || actionBusy.value) {
return
}
async function createDraftExpenseItem({ openEditor = true } = {}) {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法新增费用明细。')
return
return null
}
creatingExpense.value = true
@@ -1441,15 +1860,108 @@ export default {
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
creatingExpense.value = false
startExpenseEdit(nextItem)
toast('已新增一条费用明细,请继续填写。')
if (openEditor) {
startExpenseEdit(nextItem)
toast('已新增一条费用明细,请继续填写。')
}
return nextItem
} catch (error) {
toast(error?.message || '新增费用明细失败,请稍后重试。')
return null
} finally {
creatingExpense.value = false
}
}
async function handleAddExpenseItem() {
if (!isEditableRequest.value || actionBusy.value) {
return
}
await createDraftExpenseItem({ openEditor: true })
}
function triggerSmartEntryUpload() {
if (!isEditableRequest.value || actionBusy.value) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法上传单据。')
return
}
smartEntrySelectedFiles.value = []
smartEntryUploadDialogOpen.value = true
}
function closeSmartEntryUploadDialog() {
if (smartEntryUploadBusy.value) {
return
}
smartEntryUploadDialogOpen.value = false
clearSmartEntryFile()
}
function chooseSmartEntryFile() {
if (smartEntryUploadBusy.value) {
return
}
if (smartEntryUploadInput.value) {
smartEntryUploadInput.value.value = ''
smartEntryUploadInput.value.click()
}
}
function clearSmartEntryFile() {
smartEntrySelectedFiles.value = []
if (smartEntryUploadInput.value) {
smartEntryUploadInput.value.value = ''
}
}
function handleSmartEntryFileChange(event) {
const target = event?.target
const fileList = target?.files
const files = Array.from(fileList || [])
if (target) {
target.value = ''
}
if (!files.length) {
return
}
smartEntrySelectedFiles.value = files
}
async function confirmSmartEntryUpload() {
if (smartEntryUploadBusy.value) {
return
}
const files = [...smartEntrySelectedFiles.value]
if (!files.length) {
toast('请先选择需要智能录入的附件。')
return
}
smartEntryUploadDialogOpen.value = false
clearSmartEntryFile()
const { task, reused } = startSmartEntryRecognitionTask({
claimId: request.value.claimId,
files,
itemSnapshots: expenseItems.value
})
if (!task) {
toast('当前草稿缺少 claimId暂时无法识别附件。')
return
}
bindSmartEntryRecognitionTask(request.value.claimId)
toast(reused ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。')
}
function triggerExpenseUpload(item) {
if (!isEditableRequest.value || actionBusy.value) {
return
@@ -1570,31 +2082,7 @@ export default {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
applyClaimRiskFlagsPayload(payload)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (recognizedItemDate) {
itemPatch.itemDate = recognizedItemDate
}
if (recognizedItemType) {
itemPatch.itemType = recognizedItemType
}
if (recognizedItemReason) {
itemPatch.itemReason = recognizedItemReason
}
if (recognizedItemLocation) {
itemPatch.itemLocation = recognizedItemLocation
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
@@ -1603,8 +2091,10 @@ export default {
emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
return true
} catch (error) {
toast(error?.message || '附件上传失败,请稍后重试。')
return false
} finally {
uploadingExpenseId.value = ''
}
@@ -1693,6 +2183,7 @@ export default {
expenseEditor.itemReason = ''
expenseEditor.itemLocation = ''
expenseEditor.itemAmount = ''
expenseEditor.itemNote = ''
expenseEditor.invoiceId = ''
}
if (pendingUploadExpenseId.value === item.id) {
@@ -1736,6 +2227,7 @@ export default {
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation,
item_note: expenseEditor.itemNote.trim(),
item_amount: nextAmount,
invoice_id: nextInvoiceId
}
@@ -1748,6 +2240,7 @@ export default {
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation,
itemNote: expenseEditor.itemNote.trim(),
itemAmount: nextAmount,
invoiceId: nextInvoiceId
})
@@ -1788,11 +2281,6 @@ export default {
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true
}
@@ -1823,12 +2311,6 @@ export default {
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false
openRiskOverrideDialog()
return
}
submitBusy.value = true
try {
const payload = await submitExpenseClaim(request.value.claimId)
@@ -2007,26 +2489,6 @@ export default {
}
}
function openAiEntry() {
if (!canOpenAiEntry.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value,
restoreLatestConversation: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
})
}
function buildApplicationEditPreview() {
const factEntries = applicationDetailFactItems.value
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
@@ -2098,6 +2560,14 @@ export default {
}
onBeforeUnmount(() => {
if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer)
highlightedRiskCardTimer = 0
}
if (stopSmartEntryRecognitionTask) {
stopSmartEntryRecognitionTask()
stopSmartEntryRecognitionTask = null
}
closeAttachmentPreview()
})
@@ -2112,9 +2582,10 @@ export default {
canNavigateAttachmentPreview,
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons,
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
@@ -2123,20 +2594,27 @@ export default {
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
focusExpenseRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
handleModifyApplication,
handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
hasExpenseRiskIndicator,
hasExpenseRiskOrAbnormal,
triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
smartEntryRecognitionBusy, smartEntryRecognitionText,
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,

View File

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

View File

@@ -448,18 +448,14 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
].join('\n')
const reviewFormValues = {
expense_type: typeLabel,
reimbursement_type: typeLabel,
reason: values.reason || applicationReason || values.customer_name || '',
reason_value: values.reason || applicationReason || '',
customer_name: values.customer_name || '',
participants: values.participants || '',
location: values.location || applicationLocation || '',
business_location: values.location || applicationLocation || '',
time_range: values.time_range || applicationBusinessTime || '',
business_time: values.time_range || applicationBusinessTime || '',
transport_mode: values.transport_mode || applicationTransportMode || '',
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
attachments: Array.isArray(values.attachment_names) ? values.attachment_names.join('、') : '',
application_claim_id: values.application_claim_id || '',
application_claim_no: values.application_claim_no || '',
application_reason: values.application_reason || '',

View File

@@ -64,6 +64,68 @@ export function buildReviewFormValues(fields) {
}, {})
}
const ONTOLOGY_REVIEW_FIELD_ALIASES = {
expense_type: ['reimbursement_type', 'scene_label', 'expenseType'],
time_range: ['business_time', 'businessTime', 'occurred_date', 'occurredDate'],
location: ['business_location', 'businessLocation'],
reason: ['reason_value', 'reasonValue', 'business_reason', 'businessReason'],
transport_mode: ['transport_type', 'transportType', 'transportMode', 'application_transport_mode', 'applicationTransportMode'],
attachments: ['attachment_names', 'attachmentNames'],
customer_name: ['customerName'],
merchant_name: ['merchantName']
}
const ONTOLOGY_REVIEW_CONTEXT_FIELDS = new Set([
'expense_type',
'time_range',
'location',
'reason',
'amount',
'transport_mode',
'attachments',
'customer_name',
'merchant_name',
'participants',
'application_claim_id',
'application_claim_no',
'application_reason',
'application_location',
'application_amount',
'application_amount_label',
'application_business_time',
'application_days',
'application_transport_mode',
'application_lodging_daily_cap',
'application_subsidy_daily_cap',
'application_transport_policy',
'application_policy_estimate',
'application_rule_name',
'application_rule_version',
'application_date'
])
export function normalizeReviewFormValuesToOntology(values = {}) {
const source = values && typeof values === 'object' ? values : {}
const normalized = {}
Object.entries(source).forEach(([key, value]) => {
const cleanedKey = String(key || '').trim()
if (!cleanedKey) return
normalized[cleanedKey] = String(value || '').trim()
})
Object.entries(ONTOLOGY_REVIEW_FIELD_ALIASES).forEach(([canonicalKey, aliases]) => {
if (normalized[canonicalKey]) return
const matchedAlias = aliases.find((alias) => normalized[alias])
if (matchedAlias) {
normalized[canonicalKey] = normalized[matchedAlias]
}
})
return Object.fromEntries(
Object.entries(normalized).filter(([key, value]) => ONTOLOGY_REVIEW_CONTEXT_FIELDS.has(key) && String(value || '').trim())
)
}
export function buildBusinessTimeContextFromReviewValues(values = {}) {
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
if (!timeText) {
@@ -113,12 +175,12 @@ export function buildReviewFormContextFromPayload(reviewPayload, inlineState = n
).trim()
if (inheritedTimeRange) {
values.time_range = values.time_range || inheritedTimeRange
values.business_time = values.business_time || inheritedTimeRange
}
const businessTimeContext = buildBusinessTimeContextFromReviewValues(values)
const ontologyValues = normalizeReviewFormValuesToOntology(values)
const businessTimeContext = buildBusinessTimeContextFromReviewValues(ontologyValues)
return {
review_form_values: values,
review_form_values: ontologyValues,
...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
}
}

View File

@@ -401,6 +401,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
const id = resolveExpenseItemViewId(source, index, requestModel)
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
const itemNote = String(source?.itemNote ?? source?.item_note ?? '').trim()
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
@@ -421,6 +422,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
itemType,
itemReason,
itemLocation,
itemNote,
itemAmount,
invoiceId,
isSystemGenerated,

View File

@@ -442,6 +442,9 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
itemId: normalizeId(item?.id),
itemIndex: index + 1,
invoiceId: normalizeText(item?.invoiceId),
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone,
label: resolveRiskLevelLabel(tone),
@@ -631,6 +634,9 @@ export function buildAttachmentRiskCards({
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
itemId: flagItemId,
itemIndex: Number(flag.item_index ?? flag.itemIndex ?? 0) || null,
invoiceId: normalizeText(flag.invoice_id || flag.invoiceId),
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
tone,
label: resolveRiskLevelLabel(tone),

View File

@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
}
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
}

View File

@@ -319,13 +319,9 @@ export function useTravelReimbursementGuidedFlow({
},
review_form_values: {
expense_type: expenseTypeLabel,
reimbursement_type: expenseTypeLabel,
reason: applicationReason,
reason_value: applicationReason,
location: applicationLocation,
business_location: applicationLocation,
time_range: applicationBusinessTime,
business_time: applicationBusinessTime,
transport_mode: applicationTransportMode,
amount: '',
application_claim_id: applicationId,

View File

@@ -18,6 +18,7 @@ import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplica
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
@@ -171,6 +172,78 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
function hasReceiptFolderSourceFile(files) {
return files.some((file) => String(file?.receiptId || '').trim())
}
async function promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId,
files,
fileNames,
options,
rawText,
resolvedUploadDisposition,
reviewAction,
systemGenerated,
userText
}) {
if (
isKnowledgeSession.value ||
systemGenerated ||
!files.length ||
detailScopedClaimId ||
resolvedUploadDisposition ||
options.skipReceiptFolderUnlinkedPrompt ||
options.skipDraftAssociationPrompt ||
reviewAction ||
hasReceiptFolderSourceFile(files)
) {
return false
}
let unlinkedReceipts = []
try {
unlinkedReceipts = await fetchReceiptFolderItems('unlinked')
} catch (error) {
console.warn('Failed to load unlinked receipt folder items before attachment upload:', error)
return false
}
const count = Array.isArray(unlinkedReceipts) ? unlinkedReceipts.length : 0
if (!count) {
return false
}
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
`票据夹中还有 ${count} 份未关联票据。建议先处理这些票据再上传新附件,避免重复保存或遗漏关联。`,
[],
{
meta: ['票据夹待关联'],
suggestedActions: [
{
action_type: 'open_receipt_folder',
label: '去票据夹关联',
icon: 'mdi mdi-folder-open-outline',
payload: { target_view: 'receiptFolder' }
},
{
action_type: 'continue_upload_with_unlinked_receipts',
label: '继续上传新附件',
icon: 'mdi mdi-upload-outline',
payload: { raw_text: rawText }
}
]
}
))
nextTick(scrollToBottom)
persistSessionState()
return true
}
function buildConfirmedAssociationText(message) {
return String(message?.text || '')
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
@@ -653,6 +726,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return null
}
if (await promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId,
files,
fileNames,
options,
rawText,
resolvedUploadDisposition,
reviewAction,
systemGenerated,
userText
})) {
return null
}
const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value &&
files.length &&

View File

@@ -75,6 +75,21 @@ test('documents center reloads immediately when entered or clicked again', () =>
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
})
test('document detail navigation preserves document center list query', () => {
assert.match(
appShellComposable,
/function openRequestDetail\(request\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: \{ \.\.\.route\.query \}/
)
assert.match(
appShellComposable,
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
)
assert.match(
appShellComposable,
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
)
})
test('application entry keeps its own assistant source without creating a separate dialog', () => {
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)

View File

@@ -35,13 +35,35 @@ test('documents center top tabs start from all and show document category labels
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)\)/)
assert.match(documentsCenterView, /const initialScopeTab = resolveInitialScopeTab\(\)/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(initialScopeTab\)/)
assert.match(
documentsCenterView,
/function resolveInitialScopeTab\(\) \{[\s\S]*readDocumentCenterQueryText\('dc_scope'\)[\s\S]*return readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)/
)
assert.match(
documentsCenterView,
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
)
})
test('documents center persists pagination and filters in route query for detail return', () => {
assert.match(documentsCenterView, /import \{ useRoute, useRouter \} from 'vue-router'/)
assert.match(documentsCenterView, /const DOCUMENT_CENTER_QUERY_KEYS = new Set\(/)
assert.match(documentsCenterView, /'dc_page'/)
assert.match(documentsCenterView, /'dc_page_size'/)
assert.match(documentsCenterView, /const currentPage = ref\(readDocumentCenterQueryNumber\('dc_page', 1\)\)/)
assert.match(documentsCenterView, /const pageSize = ref\(resolveInitialPageSize\(\)\)/)
assert.match(
documentsCenterView,
/function buildDocumentCenterRouteQuery\(\) \{[\s\S]*nextQuery\.dc_page = String\(currentPage\.value\)[\s\S]*nextQuery\.dc_page_size = String\(pageSize\.value\)/
)
assert.match(
documentsCenterView,
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
)
})
test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)

View File

@@ -21,6 +21,10 @@ const barChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)),
'utf8'
)
const trendChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/TrendChart.vue', import.meta.url)),
'utf8'
)
test('finance dashboard keeps legacy ranking range constants for backend compatibility', () => {
assert.deepEqual(departmentRangeOptions, [
@@ -57,3 +61,33 @@ test('finance ranking bar chart can display ranking metadata', () => {
assert.match(overviewViewModel, /meta: `\$\{Number\(item\.employeeCount/)
assert.match(overviewViewModel, /meta: `\$\{item\.department/)
})
test('daily amount trend uses stacked category bars with clear unit and legend', () => {
assert.match(overviewView, /:category-amount-series="activeTrend\.categoryAmountSeries"/)
assert.match(overviewView, /:key="`finance-amount-\$\{financeDashboardRenderKey\}`"/)
assert.match(overviewView, /:key="`finance-count-\$\{financeDashboardRenderKey\}`"/)
assert.match(overviewView, /return financeDashboardLoading\.value\s*\n\}/)
assert.doesNotMatch(overviewView, /financeDashboardLoading\.value && !financeDashboardLoaded\.value/)
assert.match(overviewViewModel, /categoryAmountSeries: \[\]/)
assert.match(overviewViewModel, /financeDashboardRenderKey/)
assert.match(overviewViewModel, /financeDashboardRequestSeq/)
assert.match(overviewViewModel, /requestSeq !== financeDashboardRequestSeq/)
assert.match(overviewViewModel, /financeDashboardRenderKey\.value \+= 1/)
assert.match(trendChart, /categoryAmountSeries/)
assert.match(trendChart, /CustomChart as EChartsCustomChart/)
assert.match(trendChart, /type: 'custom'/)
assert.match(trendChart, /renderStackedAmountBar/)
assert.match(trendChart, /resolveCategoryColor/)
assert.match(trendChart, /expenseCategoryColorMap/)
assert.match(trendChart, /clipPath/)
assert.match(trendChart, /enterFrom/)
assert.match(trendChart, /originY: zeroY/)
assert.match(trendChart, /scaleY: 0/)
assert.match(trendChart, /chart-unit/)
assert.match(trendChart, /unitLabel/)
assert.match(trendChart, /legendItems/)
assert.match(trendChart, /单位:元/)
assert.match(trendChart, /单位:单/)
assert.doesNotMatch(trendChart, /name:\s*isCountMode\.value/)
assert.doesNotMatch(trendChart, /stack: 'expenseAmount'/)
})

View File

@@ -13,30 +13,38 @@ function testReceiptFolderViewSurface() {
assert.match(view, /activeStatus = ref\('all'\)/)
assert.match(view, /value: 'all'/)
assert.match(view, /value: 'unlinked'/)
assert.match(view, /value: 'linked'/)
assert.match(view, /openAssociateDialog/)
assert.match(view, /receipt-detail-toolbar/)
assert.match(view, /receipt-dashboard/)
assert.match(view, /receipt-dashboard-preview/)
assert.match(view, /receipt-dashboard-side/)
assert.match(view, /receipt-dashboard-bottom/)
assert.match(view, /receipt-ocr-panel/)
assert.match(view, /receipt-status-panel/)
assert.match(view, /keyReceiptFields/)
assert.match(view, /editableOtherFields/)
assert.match(view, /ocrPreviewFields/)
assert.match(view, /class="receipt-key-grid"/)
assert.match(view, /class="receipt-other-collapse"/)
assert.match(view, /class="receipt-other-scroll"/)
assert.match(view, /<EnterpriseDetailPage/)
assert.match(view, /variant="receipt-folder-detail"/)
assert.match(view, /<template #main>/)
assert.match(view, /<template #side>/)
assert.match(view, /<template #bottom>/)
assert.match(view, /receipt-preview-panel/)
assert.match(view, /receipt-ticket-info-panel/)
assert.match(view, /receipt-association-panel/)
assert.match(view, /receipt-edit-log-section/)
assert.match(view, /receipt-all-field-grid/)
assert.match(view, /receiptInfoEditing/)
assert.match(view, /startReceiptInfoEdit/)
assert.match(view, /cancelReceiptInfoEdit/)
assert.match(view, /receiptEditLogs/)
assert.match(view, /previewFrameUrl/)
assert.match(view, /previewTransform/)
assert.match(view, /String\(value \?\? ''\)\.trim\(\)/)
assert.match(view, /openAssociateDialogForCurrentReceipt/)
assert.match(view, /createReceiptDetailDashboardModel/)
assert.match(view, /ElCollapse/)
assert.doesNotMatch(view, /addField/)
assert.match(view, /const isTrainTicket = computed/)
assert.doesNotMatch(view, /打开源文件/)
assert.match(view, /createReceiptDetailFieldModel/)
assert.doesNotMatch(view, /receipt-detail-toolbar/)
assert.doesNotMatch(view, /receipt-side-stack/)
assert.doesNotMatch(view, /receipt-bottom-grid/)
assert.doesNotMatch(view, /receipt-status-panel/)
assert.doesNotMatch(view, /receipt-key-grid/)
assert.doesNotMatch(view, /receipt-other-collapse/)
assert.doesNotMatch(view, /ElCollapse/)
assert.doesNotMatch(view, /openSourceFile/)
assert.match(view, /back-label=/)
assert.doesNotMatch(view, /back-btn/)
assert.match(view, /deleteCurrentReceipt/)
assert.match(view, /ElCheckboxGroup/)
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
@@ -93,60 +101,66 @@ function testReceiptFolderDetailLayoutAdjustments() {
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
const fieldModel = readProjectFile('web/src/views/scripts/receiptFolderDetailFields.js')
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
const detailPage = readProjectFile('web/src/components/shared/EnterpriseDetailPage.vue')
assert.match(receiptView, /showStatusColumn/)
assert.match(receiptView, /<col v-if="showStatusColumn" class="col-status">/)
assert.match(receiptView, /<th v-if="showStatusColumn">/)
assert.match(receiptView, /document_date/)
assert.match(receiptView, /<td>\s*<strong class="doc-id">/)
assert.match(receiptView, /<td v-if="showStatusColumn">\s*<span class="status-tag"/)
assert.match(receiptView, /const activeStatus = ref\('all'\)/)
assert.match(receiptView, /import EnterpriseDetailCard/)
assert.match(receiptView, /import EnterpriseDetailPage/)
assert.match(receiptView, /<EnterpriseDetailPage/)
assert.match(receiptView, /variant="receipt-folder-detail"/)
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-basic-panel"/)
assert.match(receiptView, /receipt-dashboard-preview/)
assert.match(receiptView, /receipt-dashboard-bottom/)
assert.match(receiptView, /createReceiptDetailFieldModel/)
assert.match(receiptView, /createReceiptDetailDashboardModel/)
assert.match(receiptView, /<td[^>]*>\s*<strong class="doc-id">/)
assert.match(receiptView, /buildDetailPayload\(\)/)
assert.match(receiptView, /receiptDetailSubtitle/)
assert.match(receiptView, /receiptDetailTopBarPayload/)
assert.match(receiptView, /eyebrow:/)
assert.match(receiptView, /detail-topbar-change/)
assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/)
assert.doesNotMatch(receiptView, /class="back-btn"/)
assert.doesNotMatch(receiptView, /receipt-detail-head/)
assert.doesNotMatch(receiptView, /detail-actions receipt-detail-foot/)
assert.doesNotMatch(receiptView, /receipt-basic-panel/)
assert.doesNotMatch(receiptView, /receipt-ocr-panel/)
assert.match(receiptStyles, /\.receipt-folder-list th:first-child/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-scroll\)[\s\S]*display: flex/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-grid\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-bottom\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-actions\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/)
assert.match(receiptStyles, /\.receipt-detail-toolbar/)
assert.match(receiptStyles, /\.receipt-dashboard/)
assert.match(receiptStyles, /\.receipt-dashboard-bottom/)
assert.match(receiptStyles, /\.receipt-preview-tools/)
assert.match(receiptStyles, /\.receipt-log-list/)
assert.match(receiptStyles, /\.receipt-key-grid/)
assert.match(receiptStyles, /\.receipt-other-collapse/)
assert.match(receiptStyles, /\.receipt-other-scroll/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-head\b/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-layout\b/)
assert.doesNotMatch(receiptStyles, /\.detail-loading\b/)
assert.doesNotMatch(receiptStyles, /\.back-btn\b/)
assert.doesNotMatch(receiptStyles, /\.danger-btn\b/)
assert.match(receiptStyles, /\.receipt-preview-panel/)
assert.match(receiptStyles, /\.receipt-ticket-info-panel/)
assert.match(receiptStyles, /\.receipt-association-panel/)
assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/)
assert.match(receiptStyles, /\.receipt-all-field-grid/)
assert.match(receiptStyles, /\.receipt-edit-log-list/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)
assert.doesNotMatch(receiptStyles, /\.receipt-bottom-grid/)
assert.doesNotMatch(receiptStyles, /\.receipt-log-list/)
assert.match(detailPage, /\$slots\.bottom/)
assert.match(detailPage, /class="detail-bottom"/)
assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/)
assert.match(fieldModel, /id: 'invoice_number'/)
assert.match(fieldModel, /id: 'invoice_date'/)
assert.match(fieldModel, /id: 'fare'/)
assert.match(fieldModel, /id: 'passenger_name'/)
assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
assert.match(dashboardModel, /createReceiptDetailDashboardModel/)
assert.match(dashboardModel, /basicInfoItems/)
assert.match(dashboardModel, /operationLogs/)
assert.match(dashboardModel, /archiveInfoItems/)
assert.match(dashboardModel, /linkedClaimItems/)
assert.doesNotMatch(dashboardModel, /operationLogs/)
assert.doesNotMatch(dashboardModel, /archiveInfoItems/)
}
function testAssistantUnlinkedReceiptPrompt() {
const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js')
const assistantView = readProjectFile('web/src/views/scripts/TravelReimbursementCreateView.js')
assert.match(submitComposer, /fetchReceiptFolderItems/)
assert.match(submitComposer, /promptUnlinkedReceiptFolderIfNeeded/)
assert.match(submitComposer, /fetchReceiptFolderItems\('unlinked'\)/)
assert.match(submitComposer, /skipReceiptFolderUnlinkedPrompt/)
assert.match(submitComposer, /open_receipt_folder/)
assert.match(submitComposer, /continue_upload_with_unlinked_receipts/)
assert.match(assistantView, /actionType === 'open_receipt_folder'/)
assert.match(assistantView, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
assert.match(assistantView, /actionType === 'continue_upload_with_unlinked_receipts'/)
assert.match(assistantView, /skipReceiptFolderUnlinkedPrompt: true/)
}
function run() {
@@ -155,6 +169,7 @@ function run() {
testAppShellWiresReceiptFolder()
testSharedDocumentListStyleReuse()
testReceiptFolderDetailLayoutAdjustments()
testAssistantUnlinkedReceiptPrompt()
console.log('receipt folder view tests passed')
}

View File

@@ -74,6 +74,58 @@ test('claim mapper falls back to employee name for legacy profile lookup', () =>
assert.equal(request.profileEmployeeId, 'Legacy Alice')
})
test('claim mapper keeps low reimbursement risk as low risk instead of medium', () => {
const riskMessage = '票据商品或服务描述较笼统,建议审批人核对真实用途和明细清单。'
const request = mapExpenseClaimToRequest({
id: 'claim-low-risk-1',
claim_no: 'RE-LOW-RISK-1',
employee_name: 'Alice',
department_name: 'Finance',
expense_type: 'travel',
reason: 'Trip',
location: 'Shanghai',
amount: 354,
invoice_count: 1,
occurred_at: '2026-02-20T00:00:00.000Z',
created_at: '2026-06-03T04:22:16.000Z',
updated_at: '2026-06-03T04:25:48.000Z',
status: 'draft',
approval_stage: WAIT_SUBMIT,
risk_flags_json: [
{
source: 'submission_review',
hit_source: 'rule_center',
severity: 'low',
action: 'warning',
label: '差旅票据服务内容笼统低风险',
message: riskMessage,
risk_domain: 'invoice',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter',
business_stage: 'reimbursement'
}
],
items: [
{
id: 'item-low-risk-train',
item_date: '2026-02-20',
item_type: 'train_ticket',
item_reason: '武汉-上海',
item_location: '',
item_amount: 354,
invoice_id: 'claim-low-risk-1/item-low-risk-train/train.pdf'
}
]
})
assert.equal(request.riskTone, 'low')
assert.equal(request.riskLabel, '低风险')
assert.equal(request.riskSummary, riskMessage)
assert.equal(request.expenseItems[0].riskTone, 'low')
assert.equal(request.expenseItems[0].riskLabel, '低风险')
assert.equal(request.expenseItems[0].riskText, riskMessage)
})
test('application claims are mapped as application documents', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-1',

View File

@@ -311,13 +311,17 @@ test('guided reimbursement requires application selection for travel and enterta
assert.equal(submitOptions.extraContext.review_action, 'save_draft')
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001')
assert.equal(submitOptions.extraContext.review_form_values.reason, '去上海支持项目部署')
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
assert.equal(submitOptions.extraContext.review_form_values.location, '上海')
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
assert.equal(submitOptions.extraContext.review_form_values.application_days, '4 天')
assert.equal(submitOptions.extraContext.review_form_values.transport_mode, '火车')
assert.equal(submitOptions.extraContext.review_form_values.application_transport_mode, '火车')
assert.equal(submitOptions.extraContext.review_form_values.reimbursement_type, undefined)
assert.equal(submitOptions.extraContext.review_form_values.reason_value, undefined)
assert.equal(submitOptions.extraContext.review_form_values.business_time, undefined)
assert.equal(submitOptions.extraContext.review_form_values.business_location, undefined)
assert.equal(submitOptions.extraContext.review_form_values.application_lodging_daily_cap, '600元/天')
assert.equal(submitOptions.extraContext.review_form_values.application_subsidy_daily_cap, '120元/天')
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import {
buildReviewFormContextFromPayload,
buildLocallySyncedReviewPayload,
buildReviewNextStepRichCopy,
buildReviewPlainFollowupCopy,
@@ -410,6 +411,40 @@ test('continuing receipt upload preserves prior review form context', () => {
)
})
test('review form context emits ontology fields instead of local aliases', () => {
const context = buildReviewFormContextFromPayload(
{
edit_fields: [
{ key: 'expense_type', value: '' },
{ key: 'occurred_date', value: '' },
{ key: 'transport_type', value: '' },
{ key: 'reason', value: '' },
{ key: 'amount', value: '' },
{ key: 'business_location', value: '' },
{ key: 'attachment_names', value: '' }
]
},
{
expense_type: '差旅费',
occurred_date: '2026-06-01 至 2026-06-03',
transport_type: '火车',
reason_value: '支撑国网仿生产环境部署',
location: '上海',
amount: '3000',
attachment_names: 'ticket.pdf'
}
)
assert.equal(context.review_form_values.expense_type, '差旅费')
assert.equal(context.review_form_values.time_range, '2026-06-01 至 2026-06-03')
assert.equal(context.review_form_values.transport_mode, '火车')
assert.equal(context.review_form_values.reason, '支撑国网仿生产环境部署')
assert.equal(context.review_form_values.attachments, 'ticket.pdf')
assert.equal(context.review_form_values.occurred_date, undefined)
assert.equal(context.review_form_values.transport_type, undefined)
assert.equal(context.review_form_values.reason_value, undefined)
})
test('review drawer save action is disabled while receipt recognition is submitting', () => {
assert.match(createViewScript, /const submitting = ref\(false\)/)
assert.match(

View File

@@ -516,7 +516,7 @@ test('AI advice template renders grouped section titles with completion before r
})
test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
@@ -538,13 +538,25 @@ test('AI advice risk section uses compact card styling hooks', () => {
})
test('expense rows show a major-risk warning icon before time', () => {
assert.match(detailViewTemplate, /'has-major-risk': isMajorExpenseRisk\(item\)/)
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/)
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/)
assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
})
test('expense risk indicator can focus and flash related risk card', () => {
assert.match(detailViewTemplate, /:id="resolveRiskCardDomId\(card\)"/)
assert.match(detailViewTemplate, /:data-risk-card-id="card\.id"/)
assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'center' \}\)/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
})
test('AI advice shows only the latest manual return while preserving return count context', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
@@ -640,7 +652,7 @@ test('ticket item types and system allowance row are visible but read only', ()
assert.match(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
assert.match(detailViewTemplate, /v-else-if="item\.isSystemGenerated" class="system-attachment-note"/)
assert.match(detailViewScript, /系统自动计算的补贴行不能手动编辑/)
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
})
@@ -664,13 +676,29 @@ test('expense detail table shows each item filled time from item creation time',
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 6 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 7 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
})
test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
assert.match(detailViewScript, /itemNote: ''/)
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)
assert.match(detailViewScript, /item_note: expenseEditor\.itemNote\.trim\(\)/)
assert.match(detailViewScript, /itemNote: expenseEditor\.itemNote\.trim\(\)/)
assert.match(detailViewScript, /function hasExpenseRiskOrAbnormal\(item\)/)
assert.match(detailExpenseModelScript, /const itemNote = String\(source\?\.itemNote \?\? source\?\.item_note \?\? ''\)\.trim\(\)/)
assert.match(requestsComposableScript, /const itemNote = String\(item\?\.item_note \|\| item\?\.itemNote \|\| ''\)\.trim\(\)/)
})
test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
assert.doesNotMatch(
detailViewTemplate,
/ref="expenseUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleExpenseFileChange"/
)
assert.equal(
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
2
@@ -682,6 +710,34 @@ test('expense item upload remains limited to one receipt per detail row', () =>
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
})
test('detail smart entry confirms receipt upload before running recognition', () => {
assert.match(detailViewTemplate, /@click="triggerSmartEntryUpload"/)
assert.match(detailViewTemplate, /ref="smartEntryUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleSmartEntryFileChange"/)
assert.match(detailViewTemplate, /:open="smartEntryUploadDialogOpen"/)
assert.match(detailViewTemplate, /v-if="smartEntryRecognitionBusy" class="expense-recognition-banner"/)
assert.match(detailViewTemplate, /uploadingExpenseId === item\.id" class="system-attachment-note pending"/)
assert.match(detailViewTemplate, /title="上传报销附件"/)
assert.match(detailViewTemplate, /@click="chooseSmartEntryFile"/)
assert.match(detailViewTemplate, /@click="clearSmartEntryFile"/)
assert.match(detailViewTemplate, /@confirm="confirmSmartEntryUpload"/)
assert.match(detailViewScript, /const smartEntryUploadDialogOpen = ref\(false\)/)
assert.match(detailViewScript, /const smartEntryRecognitionBusy = ref\(false\)/)
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*smartEntryRecognitionBusy\.value/)
assert.match(detailViewScript, /const smartEntrySelectedFiles = ref\(\[\]\)/)
assert.match(detailViewScript, /function triggerSmartEntryUpload\(\)[\s\S]*smartEntryUploadDialogOpen\.value = true/)
assert.match(detailViewScript, /function handleSmartEntryFileChange\(event\)/)
assert.match(detailViewScript, /smartEntrySelectedFiles\.value = files/)
assert.match(detailViewScript, /function startSmartEntryRecognitionTask\(\{ claimId, files, itemSnapshots \}\)/)
assert.match(detailViewScript, /function subscribeSmartEntryRecognitionTask\(claimId, listener\)/)
assert.match(detailViewScript, /const smartEntryRecognitionCurrent = ref\(0\)/)
assert.match(detailViewScript, /return `附件识别中(\$\{current\}\/\$\{total\}),请稍候。识别完成前暂不可编辑费用明细。`/)
assert.match(detailViewScript, /const \{ task, reused \} = startSmartEntryRecognitionTask\(\{[\s\S]*claimId: request\.value\.claimId[\s\S]*itemSnapshots: expenseItems\.value/)
assert.match(detailViewScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
assert.match(detailViewScript, /void runSmartEntryRecognitionTask\(task, pendingFiles\)/)
assert.match(detailViewScript, /const payload = await uploadExpenseClaimItemAttachment\(task\.claimId, targetItem\.id, file\)/)
assert.doesNotMatch(detailViewScript, /function openAiEntry\(\)[\s\S]*emit\('openAssistant'/)
})
test('expense item upload patches OCR amount into the visible detail row', () => {
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
assert.match(detailViewScript, /const recognizedItemDate = normalizeIsoDateValue\(payload\?\.item_date \?\? payload\?\.itemDate\)/)
@@ -701,6 +757,7 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
assert.match(detailViewScript, /const amountText = String\(expenseEditor\.itemAmount \|\| ''\)\.trim\(\)/)
assert.match(detailViewScript, /const nextAmount = amountText \? Number\(amountText\) : 0/)
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
assert.match(detailViewScript, /itemPayload = \{[\s\S]*item_note: expenseEditor\.itemNote\.trim\(\)/)
})
test('travel detail AI advice uses material prompts only for required hotel receipts', () => {

View File

@@ -63,17 +63,22 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
})
test('detail submit requires override reasons for high-risk claims', () => {
test('detail submit no longer requires a separate high-risk override dialog', () => {
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /重大风险/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/)
assert.match(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.doesNotMatch(handleSubmit, /openRiskOverrideDialog/)
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
assert.doesNotMatch(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s)
assert.match(detailViewScript, /超标说明:\$\{tags\}/)
assert.match(detailViewTemplate, /异常说明/)
})
test('detail header and fallback progress use reimbursement wording', () => {