feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

BIN
web/UI/预算中心.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -36,6 +36,8 @@
.risk-sim-context-panel span,
.risk-sim-result-head span,
.risk-sim-evidence span,
.risk-sim-recognition-debug > span,
.risk-sim-recognized-fields > span,
.risk-sim-file-strip > span {
color: #64748b;
font-size: 12px;
@@ -115,6 +117,12 @@
color: #b91c1c;
}
.risk-sim-meta .tone-critical {
border-color: #fca5a5;
background: #fff1f2;
color: #991b1b;
}
.risk-sim-main {
min-height: 0;
display: grid;
@@ -263,6 +271,78 @@
background: rgba(255, 255, 255, 0.12);
}
.risk-sim-recognition-debug {
display: grid;
gap: 8px;
margin-top: 11px;
}
.risk-sim-recognition-debug article {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.risk-sim-recognition-debug article header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 0;
}
.risk-sim-recognition-debug article header strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-sim-recognition-debug article header em {
flex: 0 0 auto;
color: #64748b;
font-size: 11px;
font-style: normal;
}
.risk-sim-debug-field-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.risk-sim-debug-field-list b {
max-width: 100%;
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 8px;
overflow: hidden;
border: 1px solid #dbeafe;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 11px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-sim-debug-ocr-text {
max-height: 112px;
overflow: auto;
padding: 8px 9px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #334155;
}
.risk-sim-result-card {
margin-top: 12px;
overflow: hidden;
@@ -364,6 +444,53 @@
white-space: nowrap;
}
.risk-sim-recognized-fields {
display: grid;
gap: 7px;
padding: 0 12px 12px;
}
.risk-sim-recognized-fields ul {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.risk-sim-recognized-fields li {
min-width: 0;
display: grid;
grid-template-columns: minmax(140px, 0.75fr) auto minmax(130px, 1fr);
gap: 8px;
align-items: center;
padding: 8px 9px;
border: 1px solid #edf2f7;
border-radius: 10px;
background: #f8fafc;
}
.risk-sim-recognized-fields strong,
.risk-sim-recognized-fields b {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-sim-recognized-fields em {
padding: 3px 7px;
border-radius: 999px;
background: #ecfdf5;
color: #047857;
font-size: 11px;
font-style: normal;
font-weight: 850;
white-space: nowrap;
}
.risk-sim-evidence {
display: grid;
gap: 6px;
@@ -730,6 +857,10 @@
grid-template-columns: 1fr;
}
.risk-sim-recognized-fields li {
grid-template-columns: 1fr;
}
.risk-sim-foot {
align-items: stretch;
flex-direction: column;

View File

@@ -935,6 +935,7 @@
font-weight: 850;
}
.risk-rule-create-form input:not([type='checkbox']),
.risk-rule-create-form select,
.risk-rule-create-form textarea {
width: 100%;
@@ -945,6 +946,7 @@
font-size: 13px;
}
.risk-rule-create-form input:not([type='checkbox']),
.risk-rule-create-form select {
min-height: 42px;
padding: 0 12px;
@@ -957,6 +959,7 @@
line-height: 1.6;
}
.risk-rule-create-form input:not([type='checkbox']):focus,
.risk-rule-create-form select:focus,
.risk-rule-create-form textarea:focus {
outline: 0;
@@ -964,6 +967,7 @@
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.risk-rule-create-form input:not([type='checkbox'])::placeholder,
.risk-rule-create-form textarea::placeholder {
color: #94a3b8;
}
@@ -1045,6 +1049,118 @@
font-size: 18px;
}
.risk-level-switch {
position: relative;
display: inline-flex;
}
.minor-action.risk-level-action {
padding-right: 10px;
}
.minor-action.risk-level-action b {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 850;
}
.minor-action.risk-level-action.low {
border-color: rgba(37, 99, 235, 0.22);
color: #1d4ed8;
}
.minor-action.risk-level-action.low b {
background: #eff6ff;
color: #1d4ed8;
}
.minor-action.risk-level-action.medium {
border-color: rgba(249, 115, 22, 0.26);
color: #c2410c;
}
.minor-action.risk-level-action.medium b {
background: #fff7ed;
color: #c2410c;
}
.minor-action.risk-level-action.high {
border-color: rgba(220, 38, 38, 0.22);
color: #b91c1c;
}
.minor-action.risk-level-action.high b {
background: #fef2f2;
color: #b91c1c;
}
.risk-level-menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 35;
width: 146px;
padding: 6px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.16);
}
.risk-level-option {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 12px;
font-weight: 800;
text-align: left;
}
.risk-level-option:hover,
.risk-level-option.active {
background: #f8fafc;
}
.risk-level-option:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.risk-level-option i {
margin-left: auto;
font-size: 15px;
}
.risk-level-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #94a3b8;
}
.risk-level-option.low .risk-level-dot {
background: #2563eb;
}
.risk-level-option.medium .risk-level-dot {
background: #f97316;
}
.risk-level-option.high .risk-level-dot {
background: #dc2626;
}
.minor-action.danger-action {
border-color: rgba(220, 38, 38, 0.2);
color: #dc2626;
@@ -1174,6 +1290,8 @@
.search-filter,
.picker-trigger,
.picker-filter,
.risk-level-switch,
.risk-level-switch .minor-action,
.toolbar-actions > * {
width: 100%;
}
@@ -1303,6 +1421,62 @@
padding-bottom: 8px;
}
.json-risk-score-ring {
--score-ring: #f97316;
--score-ring-bg: #fff7ed;
flex: 0 0 auto;
width: 82px;
height: 82px;
border-radius: 999px;
display: grid;
place-items: center;
align-content: center;
gap: 1px;
border: 2px solid var(--score-ring);
background: var(--score-ring-bg);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.json-risk-score-ring strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
line-height: 1;
}
.json-risk-score-ring span,
.json-risk-score-ring em {
color: #64748b;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 1.1;
}
.json-risk-score-ring em {
color: var(--score-ring);
}
.json-risk-score-ring.low {
--score-ring: #2563eb;
--score-ring-bg: #eff6ff;
}
.json-risk-score-ring.medium {
--score-ring: #f97316;
--score-ring-bg: #fff7ed;
}
.json-risk-score-ring.high {
--score-ring: #dc2626;
--score-ring-bg: #fef2f2;
}
.json-risk-score-ring.critical {
--score-ring: #991b1b;
--score-ring-bg: #fff1f2;
}
.json-risk-editor-title {
min-width: 0;
display: flex;
@@ -1423,6 +1597,55 @@
padding: 0 2px 2px 0;
}
.json-risk-generation-failure {
min-height: 360px;
display: grid;
place-items: center;
align-content: center;
gap: 14px;
padding: 44px 24px;
border: 1px solid #fecaca;
border-radius: 12px;
background: #fffafa;
text-align: center;
color: #475569;
}
.json-risk-generation-failure i {
width: 54px;
height: 54px;
display: grid;
place-items: center;
border-radius: 18px;
background: #fef2f2;
color: #dc2626;
font-size: 28px;
}
.json-risk-generation-failure h3 {
margin: 0;
color: #991b1b;
font-size: 18px;
font-weight: 850;
}
.json-risk-generation-failure p {
max-width: 520px;
margin: 8px auto 0;
color: #475569;
font-size: 13px;
line-height: 1.7;
}
.json-risk-generation-failure small {
display: block;
max-width: 640px;
margin-top: 12px;
color: #b91c1c;
line-height: 1.6;
word-break: break-word;
}
.json-risk-main-stage {
min-width: 0;
min-height: 0;
@@ -1521,14 +1744,19 @@
color: #dc2626;
}
.json-risk-meta-badge.critical {
background: #fff1f2;
color: #991b1b;
}
.json-risk-meta-badge.medium {
background: #fff7ed;
color: #ea580c;
}
.json-risk-meta-badge.low {
background: #ecfdf5;
color: #059669;
background: #eff6ff;
color: #1d4ed8;
}
.json-risk-meta-badge.test-passed {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,499 @@
.budget-center-page {
min-width: 0;
display: grid;
gap: 14px;
color: #1f2937;
}
.budget-local-head {
min-height: 34px;
display: flex;
align-items: center;
}
.budget-local-head h2 {
margin: 0;
color: #111827;
font-size: 24px;
line-height: 1.2;
font-weight: 800;
}
.budget-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #e5eaf1;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.budget-summary-card {
min-height: 118px;
padding: 22px 28px;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: center;
gap: 18px;
border-right: 1px solid #edf1f6;
}
.budget-summary-card:last-child {
border-right: 0;
}
.summary-icon {
width: 54px;
height: 54px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 30px;
}
.summary-icon.green {
background: #e8f7ef;
color: #07965f;
}
.summary-icon.blue {
background: #edf4ff;
color: #2f7fd7;
}
.summary-icon.orange {
background: #fff4e5;
color: #df9300;
}
.budget-summary-card span:not(.summary-icon) {
display: block;
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.budget-summary-card strong {
display: block;
margin-top: 8px;
color: #111827;
font-size: 24px;
line-height: 1;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.budget-summary-card em {
display: block;
margin-top: 10px;
color: #8a94a6;
font-size: 13px;
font-style: normal;
}
.budget-filter-bar {
min-height: 62px;
border: 1px solid #e5eaf1;
border-radius: 8px;
background: #fff;
padding: 12px 18px;
display: flex;
align-items: center;
gap: 22px;
}
.budget-filter-bar label {
display: inline-flex;
align-items: center;
gap: 10px;
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.budget-filter-bar select,
.budget-table-foot select {
height: 34px;
min-width: 150px;
border: 1px solid #dbe2ec;
border-radius: 5px;
background: #fff;
color: #1f2937;
padding: 0 34px 0 12px;
font-size: 14px;
}
.budget-primary-btn {
margin-left: auto;
height: 36px;
border: 0;
border-radius: 5px;
background: #0aa66f;
color: #fff;
padding: 0 18px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 800;
}
.budget-work-grid {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 10px;
}
.budget-department-panel,
.budget-table-panel,
.budget-chart-panel,
.budget-alert-panel {
border: 1px solid #e5eaf1;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.budget-department-panel header,
.budget-table-panel > header,
.budget-card-head {
min-height: 48px;
padding: 13px 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f6;
}
.budget-department-panel strong,
.budget-table-panel > header strong,
.budget-card-head strong {
color: #111827;
font-size: 16px;
font-weight: 800;
}
.department-search {
position: relative;
margin: 12px 14px 8px;
}
.department-search i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #9aa5b5;
}
.department-search input {
width: 100%;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 0 12px 0 34px;
background: #fff;
color: #1f2937;
}
.department-list {
display: grid;
gap: 4px;
padding: 8px 12px 16px;
}
.department-list button {
height: 38px;
border: 0;
border-radius: 5px;
background: transparent;
color: #4b5563;
padding: 0 12px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 700;
text-align: left;
}
.department-list button.active {
background: #e9f7f1;
color: #07965f;
}
.budget-table-wrap {
overflow-x: auto;
}
.budget-table-panel table {
width: 100%;
min-width: 1040px;
border-collapse: collapse;
}
.budget-table-panel th,
.budget-table-panel td {
padding: 13px 18px;
border-bottom: 1px solid #edf1f6;
border-right: 1px solid #edf1f6;
color: #273142;
font-size: 14px;
text-align: left;
white-space: nowrap;
}
.budget-table-panel th:last-child,
.budget-table-panel td:last-child {
border-right: 0;
}
.budget-table-panel th {
background: #fafbfd;
color: #1f2937;
font-weight: 800;
}
.budget-rate {
width: 96px;
display: grid;
gap: 6px;
}
.budget-rate span {
color: #273142;
font-size: 13px;
}
.budget-rate div {
height: 6px;
border-radius: 999px;
background: #e9edf3;
overflow: hidden;
}
.budget-rate em {
display: block;
height: 100%;
border-radius: inherit;
}
.budget-rate em.ok {
background: #13a66b;
}
.budget-rate em.warn {
background: #f2a51a;
}
.budget-rate em.danger {
background: #ef4444;
}
.budget-warning-red {
color: #e24b4b !important;
font-weight: 800;
}
.budget-warning-yellow {
color: #e3a008 !important;
font-weight: 800;
}
.budget-row-actions {
display: flex;
align-items: center;
gap: 14px;
}
.budget-row-actions button {
border: 0;
background: transparent;
color: #1c7ed6;
font-size: 14px;
font-weight: 800;
}
.budget-table-foot {
min-height: 52px;
padding: 10px 18px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.budget-table-foot button {
width: 32px;
height: 32px;
border: 1px solid #dbe2ec;
border-radius: 5px;
background: #fff;
color: #64748b;
}
.budget-table-foot button.active {
border-color: #10a873;
color: #10a873;
font-weight: 800;
}
.budget-table-foot span {
color: #4b5563;
font-size: 14px;
}
.budget-bottom-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.82fr);
gap: 10px;
}
.budget-card-head button {
border: 0;
background: transparent;
color: #1c7ed6;
font-size: 14px;
font-weight: 800;
}
.budget-chart-legend {
display: flex;
align-items: center;
gap: 22px;
color: #4b5563;
font-size: 13px;
}
.budget-chart-legend span {
display: inline-flex;
align-items: center;
gap: 7px;
}
.legend-line {
width: 18px;
height: 0;
border-top: 2px dashed #2f7fd7;
}
.legend-line.used {
border-top-style: solid;
border-top-color: #13a66b;
}
.budget-chart-panel {
padding-bottom: 16px;
}
.budget-chart-panel .budget-trend-chart {
margin: 12px 18px 0;
}
.budget-alert-list {
display: grid;
padding: 12px 20px 18px;
}
.budget-alert-row {
min-height: 46px;
display: grid;
grid-template-columns: 12px 120px minmax(0, 1fr) 92px;
gap: 12px;
align-items: center;
border-bottom: 1px solid #edf1f6;
}
.budget-alert-row:last-child {
border-bottom: 0;
}
.budget-alert-row i {
width: 8px;
height: 8px;
border-radius: 999px;
}
.budget-alert-row i.danger {
background: #ef4444;
}
.budget-alert-row i.warn {
background: #f59e0b;
}
.budget-alert-row i.ok {
background: #13a66b;
}
.budget-alert-row strong {
color: #273142;
font-size: 14px;
font-weight: 800;
}
.budget-alert-row span {
min-width: 0;
color: #4b5563;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.budget-alert-row time {
color: #6b7280;
font-size: 13px;
text-align: right;
}
@media (max-width: 1280px) {
.budget-summary-grid,
.budget-bottom-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.budget-work-grid {
grid-template-columns: 1fr;
}
.budget-department-panel {
min-height: auto;
}
.department-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.budget-summary-grid,
.budget-bottom-grid {
grid-template-columns: 1fr;
}
.budget-filter-bar {
align-items: stretch;
flex-direction: column;
}
.budget-filter-bar label,
.budget-filter-bar select,
.budget-primary-btn {
width: 100%;
}
.department-list {
grid-template-columns: 1fr;
}
.budget-alert-row {
grid-template-columns: 12px minmax(0, 1fr);
}
.budget-alert-row span,
.budget-alert-row time {
grid-column: 2;
}
}

View File

@@ -647,6 +647,10 @@
box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48);
}
.message-bubble-application-preview {
max-width: min(100%, 980px);
}
.message-bubble-review-risk-low {
border-color: rgba(37, 99, 235, 0.72);
background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.96));
@@ -759,6 +763,170 @@
margin: 0;
}
.application-preview-table {
margin-top: 12px;
overflow: hidden;
border: 1px solid #d7e4f2;
border-radius: 8px;
background: #ffffff;
color: #334155;
font-size: var(--wb-fs-bubble);
}
.application-preview-footer {
margin-top: 12px;
color: #334155;
font-size: var(--wb-fs-bubble);
line-height: 1.58;
}
.application-preview-row {
display: grid;
grid-template-columns: 108px minmax(0, 1fr);
min-height: 38px;
border-top: 1px solid #e6edf5;
}
.application-preview-row.editable {
cursor: pointer;
}
.application-preview-row.editable:hover {
background: #f8fbff;
}
.application-preview-row.editable:hover .application-preview-label,
.application-preview-row.editable:hover .application-preview-value {
background: #f8fbff;
}
.application-preview-row.editable.missing:hover .application-preview-value {
background: #fff4e6;
}
.application-preview-row.editable:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: -2px;
}
.application-preview-row:first-child {
border-top: 0;
}
.application-preview-row.head {
min-height: 34px;
background: #f8fafc;
color: #475569;
font-size: var(--wb-fs-caption);
font-weight: 850;
}
.application-preview-row > span {
min-width: 0;
display: flex;
align-items: center;
padding: 8px 12px;
}
.application-preview-label {
border-right: 1px solid #e6edf5;
background: #fbfdff;
color: #64748b;
font-weight: 760;
}
.application-preview-value {
position: relative;
gap: 8px;
color: #0f172a;
font-weight: 650;
}
.application-preview-row.missing .application-preview-value {
background: #fff7ed;
color: #b45309;
}
.application-preview-row.highlight .application-preview-label {
background: #f0fdf4;
color: #047857;
}
.application-preview-row.highlight .application-preview-value {
background: #f7fee7;
color: #166534;
font-weight: 780;
}
.application-preview-row.highlight.missing .application-preview-value {
background: #fff7ed;
color: #b45309;
}
.application-preview-text {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.45;
}
.application-preview-edit-btn {
flex: 0 0 auto;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: #eff6ff;
color: #2563eb;
cursor: pointer;
opacity: 0;
transition: opacity 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.application-preview-edit-btn i {
font-size: 14px;
}
.application-preview-row:hover .application-preview-edit-btn,
.application-preview-edit-btn:focus-visible {
opacity: 1;
}
.application-preview-edit-btn:hover:not(:disabled),
.application-preview-edit-btn:focus-visible {
border-color: #bfdbfe;
background: #dbeafe;
}
.application-preview-edit-btn:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.application-preview-input {
width: 100%;
min-width: 0;
height: 30px;
border: 1px solid #93c5fd;
border-radius: 6px;
background: #ffffff;
color: #0f172a;
font: inherit;
font-weight: 650;
line-height: 1.2;
padding: 0 9px;
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.application-preview-select {
cursor: pointer;
appearance: auto;
}
.message-answer-markdown :deep(ul),
.message-answer-markdown :deep(ol) {
margin: 0;

View File

@@ -725,12 +725,18 @@
}
.hero-fact-grid {
grid-template-columns: repeat(5, minmax(132px, 1fr));
overflow-x: auto;
grid-template-columns: minmax(280px, 1.4fr) repeat(3, minmax(0, 1fr));
overflow: visible;
}
.hero-fact {
min-width: 132px;
min-width: 0;
padding-inline: 16px;
}
.hero-fact strong {
white-space: nowrap;
word-break: normal;
}
.detail-expense-table {
@@ -822,6 +828,18 @@
white-space: normal;
}
.application-detail-facts {
grid-template-columns: 1fr;
}
.application-detail-fact {
border-left: 0;
}
.application-detail-fact:nth-child(2) {
border-top: 1px solid #edf2f7;
}
.detail-card {
padding: 14px 16px;
}

View File

@@ -180,7 +180,7 @@
.hero-fact-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: minmax(240px, 1.25fr) repeat(3, minmax(0, 1fr));
gap: 0;
}
@@ -216,13 +216,20 @@
}
.hero-fact strong {
min-width: 0;
max-width: 100%;
color: #0f172a;
font-size: 16px;
font-weight: 800;
line-height: 1.4;
overflow-wrap: anywhere;
white-space: nowrap;
}
.hero-fact:first-child strong {
font-size: 15px;
}
.hero-fact strong.amount {
font-size: 22px;
font-weight: 900;
@@ -489,6 +496,20 @@
margin-bottom: 4px;
}
.detail-card-title-with-icon {
display: inline-flex;
align-items: center;
gap: 8px;
line-height: 1.5;
}
.detail-card-title-with-icon i {
margin-top: 1px;
color: #334155;
font-size: 18px;
line-height: 1;
}
.detail-card-head p {
color: #64748b;
font-size: 12px;
@@ -571,6 +592,70 @@
white-space: pre-wrap;
}
.application-detail-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 4px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.application-detail-fact {
display: grid;
grid-template-columns: minmax(96px, 28%) minmax(0, 1fr);
min-height: 48px;
border-top: 1px solid #edf2f7;
border-left: 1px solid #edf2f7;
}
.application-detail-fact:nth-child(-n + 2) {
border-top: 0;
}
.application-detail-fact:nth-child(2n + 1) {
border-left: 0;
}
.application-detail-fact span,
.application-detail-fact strong {
display: flex;
align-items: center;
min-width: 0;
padding: 11px 14px;
line-height: 1.5;
}
.application-detail-fact span {
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.application-detail-fact strong {
border-left: 1px solid #edf2f7;
color: #0f172a;
font-size: 13px;
font-weight: 750;
overflow-wrap: anywhere;
}
.application-detail-fact.highlight span {
background: #eefcf6;
color: #047857;
}
.application-detail-fact.highlight strong {
background: #f6fef9;
}
.application-detail-fact.emphasis strong {
color: #047857;
font-weight: 850;
}
.detail-note-editor {
display: grid;
gap: 10px;
@@ -640,6 +725,54 @@
font-weight: 850;
}
.application-leader-opinion {
display: grid;
gap: 10px;
margin-top: 12px;
}
.application-leader-opinion-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #475569;
font-size: 14px;
line-height: 1.5;
}
.application-leader-opinion-head span {
display: inline-flex;
align-items: center;
gap: 8px;
color: #0f172a;
font-weight: 850;
font-size: 16px;
}
.application-leader-opinion-head span i {
margin-top: 1px;
color: #334155;
font-size: 18px;
}
.application-leader-opinion-head strong {
color: #047857;
font-weight: 800;
font-size: 14px;
}
.inline-leader-opinion {
padding: 0;
border: 0;
background: transparent;
}
.application-leader-opinion-display {
min-height: 64px;
}
.detail-expense-table {
min-width: 0;
overflow-x: auto;

View File

@@ -0,0 +1,142 @@
<template>
<div class="budget-trend-chart">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend)
const props = defineProps({
labels: { type: Array, required: true },
budget: { type: Array, required: true },
used: { type: Array, required: true }
})
const progress = useAnimationProgress([
() => props.labels,
() => props.budget,
() => props.used
], 1000)
const scaleSeries = (series) =>
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
label: '预算',
data: scaleSeries(props.budget),
borderColor: '#2f7fd7',
backgroundColor: 'rgba(47, 127, 215, 0.08)',
borderDash: [7, 5],
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#2f7fd7',
pointBorderWidth: 2,
tension: 0.34,
fill: false
},
{
label: '已发生',
data: scaleSeries(props.used),
borderColor: '#13a66b',
backgroundColor: 'rgba(19, 166, 107, 0.12)',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#13a66b',
pointBorderWidth: 2,
tension: 0.34,
fill: false
}
]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
animation: {
duration: 760,
easing: 'easeOutQuart'
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
borderWidth: 1,
bodyColor: '#475569',
titleColor: '#0f172a',
padding: 12,
displayColors: true,
callbacks: {
label(context) {
const value = Number(context.parsed.y || 0)
return `${context.dataset.label}: ${value.toLocaleString('zh-CN')}`
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: {
color: '#64748b',
font: { size: 12 }
},
border: { display: false }
},
y: {
beginAtZero: true,
max: 12000000,
grid: {
color: '#edf2f7',
drawTicks: false
},
border: { display: false },
ticks: {
color: '#64748b',
font: { size: 12 },
stepSize: 3000000,
callback(value) {
if (value === 0) return '0'
return `${Number(value) / 10000}`
}
}
}
}
}
</script>
<style scoped>
.budget-trend-chart {
position: relative;
width: 100%;
height: 220px;
}
</style>

View File

@@ -33,6 +33,7 @@
>
<span class="nav-icon" v-html="item.icon"></span>
<span class="nav-label">{{ item.displayLabel }}</span>
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</button>
</nav>
@@ -83,7 +84,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
const props = defineProps({
navItems: { type: Array, required: true },
@@ -113,19 +114,17 @@ const props = defineProps({
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
const {
badgeLabel: approvalBadgeLabel,
refreshApprovalInbox,
startApprovalInboxPolling,
stopApprovalInboxPolling
} = useApprovalInbox()
hasUnread: documentInboxHasUnread,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling
} = useDocumentCenterInbox()
const sidebarMeta = {
overview: { label: '财务总览' },
workbench: { label: '个人工作台' },
documents: { label: '单据中心' },
requests: { label: '报销中心' },
approval: { label: '审批中心' },
archive: { label: '归档中心' },
budget: { label: '预算中心' },
policies: { label: '知识管理' },
audit: { label: '任务规则中心' },
logs: { label: '日志管理' },
@@ -137,13 +136,14 @@ const decoratedNavItems = computed(() =>
props.navItems.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
badge: item.id === 'approval' ? approvalBadgeLabel.value : sidebarMeta[item.id]?.badge
hasNewMessage: item.id === 'documents' ? documentInboxHasUnread.value : false,
badge: sidebarMeta[item.id]?.badge
}))
)
onMounted(() => {
void refreshApprovalInbox()
startApprovalInboxPolling()
void refreshDocumentInbox()
startDocumentInboxPolling()
})
@@ -238,7 +238,7 @@ watch(
)
onBeforeUnmount(() => {
stopApprovalInboxPolling()
stopDocumentInboxPolling()
closeCollapsedUserMenuNow()
})
</script>
@@ -463,6 +463,16 @@ onBeforeUnmount(() => {
opacity var(--rail-fade-duration) var(--rail-motion-ease);
}
.nav-unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border: 2px solid #fff;
border-radius: 999px;
background: #ef4444;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26);
}
.rail-user {
position: relative;
min-width: 0;
@@ -668,6 +678,14 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.rail-collapsed .nav-unread-dot {
position: absolute;
top: 10px;
right: 11px;
width: 9px;
height: 9px;
}
.rail-collapsed {
overflow: visible;
}

View File

@@ -47,26 +47,11 @@
<div
v-else
class="risk-rule-flow-svg-viewport"
@mousedown="onDragStart"
@touchstart="onTouchStart"
@dblclick="resetZoom"
>
<div
class="risk-rule-flow-svg-canvas"
:style="transformStyle"
v-html="displaySvg"
></div>
<div class="diagram-zoom-controls" @mousedown.stop @touchstart.stop>
<button class="zoom-btn" @click="zoomIn" title="放大">
<i class="mdi mdi-plus"></i>
</button>
<button class="zoom-btn" @click="zoomOut" title="缩小">
<i class="mdi mdi-minus"></i>
</button>
<button class="zoom-btn" @click="resetZoom" title="重置">
<i class="mdi mdi-arrow-expand-all"></i>
</button>
</div>
</div>
</div>
</div>
@@ -74,87 +59,7 @@
</template>
<script setup>
import { computed, ref, onUnmounted } from 'vue'
const scale = ref(1)
const translateX = ref(0)
const translateY = ref(0)
const isDragging = ref(false)
const dragStart = { x: 0, y: 0 }
const transformStyle = computed(() => ({
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value})`,
transformOrigin: 'center center',
transition: isDragging.value ? 'none' : 'transform 0.15s ease-out'
}))
function onDragStart(e) {
if (e.button !== 0) return
isDragging.value = true
dragStart.x = e.clientX - translateX.value
dragStart.y = e.clientY - translateY.value
window.addEventListener('mousemove', onDragging)
window.addEventListener('mouseup', onDragEnd)
}
function onDragging(e) {
if (!isDragging.value) return
translateX.value = e.clientX - dragStart.x
translateY.value = e.clientY - dragStart.y
}
function onDragEnd() {
isDragging.value = false
window.removeEventListener('mousemove', onDragging)
window.removeEventListener('mouseup', onDragEnd)
}
function onTouchStart(e) {
if (e.touches.length !== 1) return
isDragging.value = true
const touch = e.touches[0]
dragStart.x = touch.clientX - translateX.value
dragStart.y = touch.clientY - translateY.value
window.addEventListener('touchmove', onTouchMove, { passive: false })
window.addEventListener('touchend', onTouchEnd)
}
function onTouchMove(e) {
if (!isDragging.value || e.touches.length !== 1) return
e.preventDefault()
const touch = e.touches[0]
translateX.value = touch.clientX - dragStart.x
translateY.value = touch.clientY - dragStart.y
}
function onTouchEnd() {
isDragging.value = false
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
function zoomIn() {
scale.value = Math.min(scale.value + 0.15, 3)
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.15, 0.4)
}
function resetZoom() {
scale.value = 1
translateX.value = 0
translateY.value = 0
}
onUnmounted(() => {
window.removeEventListener('mousemove', onDragging)
window.removeEventListener('mouseup', onDragEnd)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
})
import { computed } from 'vue'
const props = defineProps({
svg: { type: String, default: '' },
@@ -188,6 +93,12 @@ const PALETTES = {
accentDark: '#b91c1c',
border: '#fecaca',
surface: '#fef2f2'
},
critical: {
accent: '#991b1b',
accentDark: '#7f1d1d',
border: '#fca5a5',
surface: '#fff1f2'
}
}
@@ -198,7 +109,8 @@ const DRAWIO_PALETTES = {
green: { fill: '#ffffff', stroke: '#e2e8f0' },
low: { fill: '#eff6ff', stroke: '#bfdbfe' },
medium: { fill: '#fff7ed', stroke: '#fed7aa' },
high: { fill: '#fef2f2', stroke: '#fecaca' }
high: { fill: '#fef2f2', stroke: '#fecaca' },
critical: { fill: '#fff1f2', stroke: '#fca5a5' }
}
function normalizeText(value, fallback = '') {
@@ -222,7 +134,11 @@ function isSafeSvg(value) {
}
function isCurrentDisplaySvg(value) {
return isSafeSvg(value) && value.includes('data-risk-flow-style="review-node-only"')
return (
isSafeSvg(value) &&
value.includes('data-risk-flow-style="review-node-only"') &&
value.includes('data-risk-flow-detail="logic-v2"')
)
}
function resolvePalette(severity) {
@@ -262,10 +178,15 @@ function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13)
.join('')
}
function truncateText(value, length) {
const text = normalizeText(value)
return text.length <= length ? text : `${text.slice(0, Math.max(0, length - 1))}`
}
function node(title, body, x, y, width, height, type = 'blue') {
const palette = DRAWIO_PALETTES[type] || DRAWIO_PALETTES.blue
return `<g class="drawio-node">
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="3" ry="3" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', '#475569', 11)}
</g>`
@@ -277,7 +198,7 @@ function diamond(title, body, x, y, width, height) {
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
const palette = DRAWIO_PALETTES.yellow
return `<g class="drawio-node">
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2"/>
<text x="${cx}" y="${cy - 8}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, 8, 2), cx, cy + 12, 'middle', '#475569', 10.2)}
</g>`
@@ -291,6 +212,24 @@ function note(body) {
</g>`
}
function panel(title, rows, x, y, width, height) {
const visibleRows = (Array.isArray(rows) ? rows : [])
.filter(Boolean)
.slice(0, 4)
.map((row) => truncateText(row, 34))
const renderedRows = visibleRows.length ? visibleRows : ['读取规则字段并归一化为判断事实']
return `<g class="drawio-node panel-node">
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" ry="6" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.2"/>
<text x="${x + 16}" y="${y + 26}" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="700">${escapeSvg(title)}</text>
${renderedRows
.map(
(row, index) =>
`<text x="${x + 16}" y="${y + 48 + index * 18}" fill="#334155" font-family="${FONT}" font-size="11" font-weight="400">${escapeSvg(row)}</text>`
)
.join('')}
</g>`
}
const palette = computed(() => resolvePalette(props.severity))
const accentStyle = computed(() => ({
@@ -323,6 +262,11 @@ const flowModel = computed(() => {
evidence: normalizeText(props.flow?.evidence, '读取规则字段'),
decision: normalizeText(props.flow?.decision, '判断是否命中风险'),
basis: normalizeText(props.flow?.basis || props.flow?.decision, '根据规则字段判断是否命中风险'),
facts: Array.isArray(props.flow?.facts) ? props.flow.facts.map(normalizeText).filter(Boolean) : [],
conditions: Array.isArray(props.flow?.conditions)
? props.flow.conditions.map(normalizeText).filter(Boolean)
: [],
hitLogic: normalizeText(props.flow?.hitLogic || props.flow?.formula),
pass: normalizeText(props.flow?.pass, '未命中风险,继续流转'),
fail: normalizeText(props.flow?.fail, `命中${severityLabel},进入人工复核`)
}
@@ -336,11 +280,12 @@ const flowSteps = computed(() => [
{
title: '字段取数',
text: `读取规则所需字段,并将字段证据送入判断节点。字段:${fieldSummary.value}`,
fields: fieldDisplays.value
fields: flowModel.value.facts.length ? flowModel.value.facts : fieldDisplays.value
},
{
title: '判断依据',
text: flowModel.value.basis || flowModel.value.decision
text: flowModel.value.basis || flowModel.value.decision,
fields: flowModel.value.conditions
}
])
@@ -367,46 +312,39 @@ const displaySvg = computed(() => {
const flow = flowModel.value
const severity = props.severity
const facts = flow.facts.length ? flow.facts : fieldDisplays.value.slice(0, 4)
const conditions = flow.conditions.length ? flow.conditions : [flow.basis || flow.decision]
const hitLogic = flow.hitLogic || flow.basis || flow.decision
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
return `<svg xmlns="http://www.w3.org/2000/svg" width="860" height="360" viewBox="0 0 860 360" data-risk-flow-style="review-node-only" data-risk-flow-detail="logic-v2" role="img" aria-label="风险规则流程说明">
<defs>
<pattern id="grid" width="16" height="16" patternUnits="userSpaceOnUse">
<path d="M 16 0 L 0 0 0 16" fill="none" stroke="#e8ecef" stroke-width="0.75"/>
</pattern>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#666666"/>
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#cbd5e1"/>
</marker>
<marker id="arrow-risk" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="${palette.value.accent}"/>
</marker>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.08" />
</filter>
</defs>
<rect width="760" height="280" fill="#ffffff"/>
<rect width="760" height="280" fill="url(#grid)"/>
<rect x="0.5" y="0.5" width="759.5" height="279.5" rx="6" fill="none" stroke="#cbd5e1" stroke-width="1"/>
<rect width="860" height="360" fill="#ffffff"/>
<rect x="18" y="18" width="824" height="324" rx="8" fill="none" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="4,3"/>
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700" letter-spacing="0.05em">RULE FLOW CANVAS</text>
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700">RULE FLOW</text>
${node('业务输入', flow.start, 48, 118, 124, 60, 'neutral')}
${node('字段取数', '读取字段证据', 214, 118, 132, 60, 'blue')}
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
${node('继续流转', flow.pass, 562, 74, 126, 60, 'green')}
${node('进入复核', flow.fail, 562, 190, 126, 62, severity)}
${note(flow.basis)}
${node('业务输入', flow.start, 38, 142, 120, 62, 'neutral')}
${panel('字段事实', facts, 196, 64, 240, 128)}
${panel('判断条件', conditions, 196, 216, 382, 104)}
${diamond('命中逻辑', hitLogic, 494, 80, 122, 122)}
${node('继续流转', flow.pass, 688, 76, 122, 60, 'neutral')}
${node('进入复核', flow.fail, 688, 226, 122, 68, severity)}
<line x1="172" y1="148" x2="214" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<g>
<rect x="521" y="108" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
<text x="532" y="120" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">否</text>
</g>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<g>
<rect x="521" y="187" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
<text x="532" y="199" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">是</text>
</g>
<path d="M 158 173 H 176 V 128 H 196" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
<line x1="316" y1="192" x2="316" y2="216" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" marker-end="url(#arrow)"/>
<path d="M 436 128 H 466 V 141 H 494" fill="none" stroke="#cbd5e1" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
<line x1="555" y1="216" x2="555" y2="202" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" marker-end="url(#arrow)"/>
<path d="M 616 125 H 648 V 106 H 688" fill="none" stroke="#cbd5e1" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
<text x="651" y="119" text-anchor="middle" fill="#64748b" font-family="${FONT}" font-size="10.5" font-weight="500">否</text>
<path d="M 616 166 H 648 V 260 H 688" fill="none" stroke="${palette.value.accent}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-risk)"/>
<text x="651" y="214" text-anchor="middle" fill="${palette.value.accentDark}" font-family="${FONT}" font-size="10.5" font-weight="700">是</text>
</svg>`
})
</script>
@@ -566,7 +504,7 @@ const displaySvg = computed(() => {
.risk-rule-flow-svg-viewport {
position: relative;
width: 100%;
height: 280px;
height: 360px;
overflow: hidden;
border-radius: 6px;
background: #ffffff;
@@ -575,16 +513,11 @@ const displaySvg = computed(() => {
align-items: center;
justify-content: center;
user-select: none;
cursor: grab;
}
.risk-rule-flow-svg-viewport:active {
cursor: grabbing;
}
.risk-rule-flow-svg-canvas {
width: 760px;
height: 280px;
width: 860px;
height: 360px;
flex-shrink: 0;
}
@@ -633,7 +566,7 @@ const displaySvg = computed(() => {
}
.risk-rule-flow-image {
width: min(760px, 100%);
width: min(860px, 100%);
display: block;
pointer-events: none;
user-select: none;

View File

@@ -49,6 +49,31 @@
</span>
</div>
<div v-if="message.recognitionDocuments?.length" class="risk-sim-recognition-debug">
<span>单据识别明细</span>
<article
v-for="document in message.recognitionDocuments"
:key="`${message.id}-${document.filename}`"
>
<header>
<strong>{{ document.filename || '临时单据' }}</strong>
<em>{{ formatDocumentMeta(document) }}</em>
</header>
<p v-if="document.summary">摘要{{ document.summary }}</p>
<div v-if="document.document_fields?.length" class="risk-sim-debug-field-list">
<b
v-for="field in document.document_fields"
:key="`${document.filename}-${field.key}-${field.value}`"
>
{{ field.label }}[{{ field.key }}]{{ field.value }}
</b>
</div>
<p v-if="document.text" class="risk-sim-debug-ocr-text">
OCR原文{{ trimDebugText(document.text, 800) }}
</p>
</article>
</div>
<div v-if="message.result" class="risk-sim-result-card" :class="message.result.severity">
<div class="risk-sim-result-head">
<div>
@@ -75,6 +100,20 @@
</div>
</div>
<div
v-if="buildRecognizedFieldRows(message.result).length"
class="risk-sim-recognized-fields"
>
<span>规则实际取用字段</span>
<ul>
<li v-for="field in buildRecognizedFieldRows(message.result)" :key="field.key">
<strong>{{ field.label }}</strong>
<em>{{ field.source }}</em>
<b>{{ field.value }}</b>
</li>
</ul>
</div>
<div v-if="buildEvidenceItems(message.result).length" class="risk-sim-evidence">
<span>判断依据</span>
<ul>
@@ -262,6 +301,16 @@ import {
formatTestError,
formatTime
} from './riskRuleTestDialogUtils.js'
import {
buildDocumentBrief,
buildEvidenceItems as buildEvidenceItemsModel,
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
buildResultFields as buildResultFieldsModel,
formatDocumentMeta,
formatFieldLabel,
resolveFileStatusLabel,
trimDebugText
} from './riskRuleTestDialogDisplay.js'
const props = defineProps({
open: {
@@ -568,8 +617,9 @@ async function recognizeTemporaryFiles(files, activeSessionId) {
messages.value.push(buildMessage(
'assistant',
recognizedCount
? `已完成 ${recognizedCount} 份临时单据识别。请核对右侧识别字段,字段不足时可以直接在输入框补充。`
: '上传文件没有提取到足够字段,暂不能直接执行规则。请在输入框补充票据城市、金额、发票号等关键信息。'
? `已完成 ${recognizedCount} 份临时单据识别。下面会展示 OCR 结构化字段和原文片段,请先核对这些信息;字段不足时可以直接在输入框补充。`
: '上传文件没有提取到足够字段。下面仍会展示 OCR 返回内容,方便判断是票据质量问题还是字段映射问题。请在输入框补充城市、金额、发票号等关键信息。',
{ recognitionDocuments: documents }
))
} catch (error) {
if (!isActiveSession(activeSessionId)) return
@@ -601,52 +651,15 @@ function buildMessage(role, text, extra = {}) {
}
function buildResultFields(result) {
const values = result?.field_values && typeof result.field_values === 'object'
? result.field_values
: {}
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
key,
label: formatFieldLabel(fields.value.find((field) => field.key === key) || { key }),
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
}))
return buildResultFieldsModel(result, fields.value)
}
function buildRecognizedFieldRows(result) {
return buildRecognizedFieldRowsModel(result, fields.value)
}
function buildEvidenceItems(result) {
const evidence = result?.evidence && typeof result.evidence === 'object'
? result.evidence
: {}
const items = []
if (Array.isArray(evidence.failed_conditions)) {
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
items.push(`${formatFieldName(condition.left)}${left}${formatFieldName(condition.right)}${right}`)
})
}
if (Array.isArray(evidence.missing_fields)) {
evidence.missing_fields.slice(0, 5).forEach((field) => {
items.push(`${formatFieldName(field)} 缺失`)
})
}
if (Array.isArray(evidence.keyword_hits)) {
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
}
if (evidence.condition_summary) {
items.push(String(evidence.condition_summary))
}
return [...new Set(items)].slice(0, 5)
}
function formatFieldLabel(field) {
const key = String(field?.key || '').trim()
const label = String(field?.display || field?.label || '').trim()
if (!key) return label || '-'
if (!label || label === key) return key
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
}
function formatFieldName(key) {
return formatFieldLabel(fields.value.find((field) => field.key === key) || { key })
return buildEvidenceItemsModel(result, fields.value)
}
function toAttachmentPayload(file) {
@@ -713,23 +726,6 @@ function documentHasMeaningfulText(document) {
)
}
function buildDocumentBrief(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
if (fields.length) {
return fields.slice(0, 4).map((field) => `${field.label}${field.value}`).join('')
}
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
}
function resolveFileStatusLabel(file) {
return file.statusText || {
pending: '待发送',
recognizing: '识别中',
recognized: '已识别',
failed: '识别失败'
}[file.status] || '待识别'
}
function buildRecognitionStepDescription() {
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'

View File

@@ -0,0 +1,110 @@
export function formatFieldLabel(field) {
const key = String(field?.key || '').trim()
const label = String(field?.display || field?.label || '').trim()
if (!key) return label || '-'
if (!label || label === key) return key
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
}
export function buildResultFields(result, fields = []) {
const values = result?.field_values && typeof result.field_values === 'object'
? result.field_values
: {}
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
key,
label: formatFieldLabel(fields.find((field) => field.key === key) || { key }),
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
}))
}
export function buildRecognizedFieldRows(result, fields = []) {
const rows = Array.isArray(result?.recognized_fields) ? result.recognized_fields : []
return rows.slice(0, 12).map((field, index) => ({
key: String(field?.key || `field-${index}`),
label: formatFieldLabel(
fields.find((item) => item.key === field?.key) || {
key: field?.key,
label: field?.label
}
),
source: formatRecognitionSource(field?.source),
value: formatDebugValue(field?.value)
}))
}
export function buildEvidenceItems(result, fields = []) {
const evidence = result?.evidence && typeof result.evidence === 'object'
? result.evidence
: {}
const items = []
if (Array.isArray(evidence.failed_conditions)) {
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
items.push(`${formatFieldName(condition.left, fields)}${left}${formatFieldName(condition.right, fields)}${right}`)
})
}
if (Array.isArray(evidence.missing_fields)) {
evidence.missing_fields.slice(0, 5).forEach((field) => {
items.push(`${formatFieldName(field, fields)} 缺失`)
})
}
if (Array.isArray(evidence.keyword_hits)) {
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
}
if (evidence.condition_summary) {
items.push(String(evidence.condition_summary))
}
return [...new Set(items)].slice(0, 5)
}
export function buildDocumentBrief(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
if (fields.length) {
return fields.slice(0, 6).map((field) => `${field.label}${field.value}`).join('')
}
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
}
export function formatDocumentMeta(document) {
const labels = [
document?.document_type_label || '',
document?.scene_label || '',
document?.avg_score ? `置信度 ${Math.round(Number(document.avg_score) * 100)}%` : ''
].filter(Boolean)
return labels.join(' · ') || '未分类'
}
export function resolveFileStatusLabel(file) {
return file.statusText || {
pending: '待发送',
recognizing: '识别中',
recognized: '已识别',
failed: '识别失败'
}[file.status] || '待识别'
}
export function trimDebugText(text, maxLength = 800) {
const value = String(text || '').replace(/\s+/g, ' ').trim()
if (!value) return ''
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
}
function formatRecognitionSource(source) {
return {
manual: '手动输入',
ocr: 'OCR结构字段',
inferred: '文本推断',
model_refined: '模型过滤'
}[String(source || '').trim()] || '未标注来源'
}
function formatDebugValue(value) {
if (Array.isArray(value)) return value.map((item) => String(item ?? '')).filter(Boolean).join('、') || '-'
if (value && typeof value === 'object') return JSON.stringify(value)
return String(value ?? '-')
}
function formatFieldName(key, fields) {
return formatFieldLabel(fields.find((field) => field.key === key) || { key })
}

View File

@@ -1,8 +1,7 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApprovalInbox } from './useApprovalInbox.js'
import { useNavigation, navItems } from './useNavigation.js'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
@@ -23,14 +22,15 @@ export function useAppShell() {
const smartEntryOpen = ref(false)
const smartEntryContext = ref({
prompt: '',
source: 'requests',
source: 'documents',
request: null,
files: [],
conversation: null,
scope: null
})
const smartEntrySessionId = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null)
const { activeView, currentView, setView } = useNavigation()
@@ -49,7 +49,6 @@ export function useAppShell() {
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const { refreshApprovalInbox } = useApprovalInbox()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
@@ -83,21 +82,14 @@ export function useAppShell() {
return null
})
const detailMode = computed(() => ['app-request-detail', 'app-document-detail'].includes(route.name))
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(requestsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
})
watch(documentsListActive, (isActive, wasActive) => {
const detailMode = computed(() => route.name === 'app-document-detail')
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(documentsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
@@ -178,6 +170,10 @@ export function useAppShell() {
}
function openFinancialAssistantCreate(source) {
if (smartEntryOpen.value) {
smartEntryRevealToken.value += 1
return
}
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
@@ -237,6 +233,7 @@ export function useAppShell() {
return (
documentType === 'application'
|| documentType === 'expense_application'
|| normalizedClaimNo.startsWith('AP-')
|| normalizedClaimNo.startsWith('APP-')
)
}
@@ -266,8 +263,12 @@ export function useAppShell() {
}
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
async function openSmartEntry(payload = {}) {
if (smartEntryOpen.value) {
smartEntryRevealToken.value += 1
return
}
const conversation = await resolveSmartEntryConversation(payload)
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
@@ -294,13 +295,12 @@ export function useAppShell() {
await reloadRequests()
if (status === 'submitted') {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(
isApplicationDocument
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
)
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
router.push({ name: 'app-documents' })
return
}
toast(
@@ -310,23 +310,21 @@ export function useAppShell() {
)
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
const routeName = activeView.value === 'documents' ? 'app-document-detail' : 'app-request-detail'
router.push({
name: routeName,
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
}
async function handleRequestUpdated() {
await reloadRequests()
void refreshApprovalInbox()
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
router.push({
name: 'app-document-detail',
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: 'app-documents' })
}
async function handleRequestUpdated() {
await reloadRequests()
}
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
@@ -335,11 +333,10 @@ export function useAppShell() {
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests()
void refreshApprovalInbox()
selectedRequestSnapshot.value = null
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
}
await reloadRequests()
selectedRequestSnapshot.value = null
router.push({ name: 'app-documents' })
}
return {
activeRange,
@@ -374,9 +371,10 @@ export function useAppShell() {
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
detailAlerts,
toast,
topBarView

View File

@@ -0,0 +1,176 @@
import { computed, ref } from 'vue'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseClaims } from '../services/reimbursements.js'
import {
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
countNewDocuments,
readViewedDocumentKeys,
resolveDocumentNewKey
} from '../utils/documentCenterNewState.js'
import { mapExpenseClaimToRequest } from './useRequests.js'
const SOURCE_PRIORITY = {
owned: 1,
approval: 2,
archive: 3
}
const documentRows = ref([])
const viewedDocumentKeys = ref(readViewedDocumentKeys())
const loading = ref(false)
let refreshTimer = null
let viewedKeysListenerAttached = false
function normalizeClaimText(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function buildDocumentInboxRow(claim, source) {
const request = mapExpenseClaimToRequest(claim)
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
const documentKey = normalizeClaimText(claimId, documentNo)
return documentKey
? {
source,
claimId: claimId || documentKey,
documentNo,
documentKey: `${source}:${documentKey}`
}
: null
}
function sourcePriority(row) {
return SOURCE_PRIORITY[row?.source] || 0
}
function mergeNonArchivedRows(rows) {
const rowMap = new Map()
rows.filter(Boolean).forEach((row) => {
const key = normalizeClaimText(row.claimId, row.documentNo, row.documentKey)
if (!key) {
return
}
const current = rowMap.get(key)
if (!current || sourcePriority(row) >= sourcePriority(current)) {
rowMap.set(key, row)
}
})
return Array.from(rowMap.values())
}
function uniqueRowsByNewKey(rows) {
const seenKeys = new Set()
return rows.filter((row) => {
const key = resolveDocumentNewKey(row)
if (!key || seenKeys.has(key)) {
return false
}
seenKeys.add(key)
return true
})
}
function mapClaimsToRows(claims, source) {
return Array.isArray(claims)
? claims.map((claim) => buildDocumentInboxRow(claim, source)).filter(Boolean)
: []
}
export function buildDocumentInboxRows({ ownedClaims = [], approvalClaims = [], archivedClaims = [] } = {}) {
const ownedRows = mapClaimsToRows(ownedClaims, 'owned')
const approvalRows = mapClaimsToRows(approvalClaims, 'approval')
const archiveRows = mapClaimsToRows(archivedClaims, 'archive')
return uniqueRowsByNewKey([
...mergeNonArchivedRows([...ownedRows, ...approvalRows]),
...archiveRows
])
}
function refreshViewedDocumentKeys() {
viewedDocumentKeys.value = readViewedDocumentKeys()
}
function attachViewedKeysListener() {
if (typeof window === 'undefined' || viewedKeysListenerAttached) {
return
}
window.addEventListener(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys)
viewedKeysListenerAttached = true
}
async function readClaimList(fetcher) {
const result = await fetcher()
return Array.isArray(result) ? result : []
}
export function useDocumentCenterInbox() {
attachViewedKeysListener()
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
const hasUnread = computed(() => unreadCount.value > 0)
async function refreshDocumentInbox() {
loading.value = true
try {
const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([
readClaimList(fetchExpenseClaims),
readClaimList(fetchApprovalExpenseClaims),
readClaimList(fetchArchivedExpenseClaims)
])
documentRows.value = buildDocumentInboxRows({
ownedClaims: ownedResult.status === 'fulfilled' ? ownedResult.value : [],
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
})
refreshViewedDocumentKeys()
} finally {
loading.value = false
}
}
function startDocumentInboxPolling(intervalMs = 45000) {
stopDocumentInboxPolling()
if (typeof window === 'undefined') {
return
}
refreshTimer = window.setInterval(() => {
void refreshDocumentInbox()
}, intervalMs)
}
function stopDocumentInboxPolling() {
if (refreshTimer && typeof window !== 'undefined') {
window.clearInterval(refreshTimer)
refreshTimer = null
}
}
return {
hasUnread,
loading,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling,
unreadCount
}
}

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'documents', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings']
export const navItems = [
{
@@ -31,28 +31,12 @@ export const navItems = [
desc: '统一查看申请、报销、审批与归档。'
},
{
id: 'requests',
label: '报销中心',
navHint: '查看和管理报销单据',
icon: icons.list,
title: '报销中心',
desc: '集中查看草稿、审批进度、票据状态与风险提示。'
},
{
id: 'approval',
label: '审批中心',
navHint: '处理审批任务',
icon: icons.approval,
title: '审批中心',
desc: '按优先级处理待审批事项,控制时效与风险。'
},
{
id: 'archive',
label: '归档中心',
navHint: '查阅公司已归档财务数据',
icon: icons.archive,
title: '归档中心',
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
id: 'budget',
label: '预算中心',
navHint: '管理预算额度、预算占用与超预算预警',
icon: icons.budget,
title: '预算中心',
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
},
{
id: 'policies',
@@ -100,9 +84,7 @@ const viewRouteNames = {
overview: 'app-overview',
workbench: 'app-workbench',
documents: 'app-documents',
requests: 'app-requests',
approval: 'app-approval',
archive: 'app-archive',
budget: 'app-budget',
policies: 'app-policies',
audit: 'app-audit',
logs: 'app-logs',
@@ -114,7 +96,7 @@ const routeNameViews = Object.fromEntries(
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
)
routeNameViews['app-request-detail'] = 'requests'
routeNameViews['app-request-detail'] = 'documents'
routeNameViews['app-document-detail'] = 'documents'
routeNameViews['app-log-detail'] = 'logs'

View File

@@ -148,6 +148,7 @@ function resolveDocumentTypeMeta(claim, typeCode) {
const isApplication =
explicitType === DOCUMENT_TYPE_APPLICATION
|| explicitType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| normalizedType === 'application'
|| normalizedType.endsWith('_application')

View File

@@ -5,6 +5,7 @@ export const icons = {
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
budget: iconPath('<path d="M4 19V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14"/><path d="M4 19h16"/><path d="M8 15v-4"/><path d="M12 15V8"/><path d="M16 15v-6"/>'),
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),

View File

@@ -10,7 +10,7 @@ import LoginRouteView from '../views/LoginRouteView.vue'
import SetupRouteView from '../views/SetupRouteView.vue'
const appChildRoutes = appViews
.filter((view) => view !== 'requests' && view !== 'documents')
.filter((view) => view !== 'documents')
.map((view) => ({
path: view,
name: `app-${view}`,
@@ -71,21 +71,24 @@ const router = createRouter({
},
{
path: '/app/requests',
name: 'app-requests',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
redirect: { name: 'app-documents' }
},
{
path: '/app/requests/:requestId',
name: 'app-request-detail',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
redirect: (to) => ({
name: 'app-document-detail',
params: { requestId: to.params.requestId },
query: to.query,
hash: to.hash
})
},
{
path: '/app/approval',
redirect: { name: 'app-documents' }
},
{
path: '/app/archive',
redirect: { name: 'app-documents' }
},
{
path: '/app/logs/:logKind/:logId',

View File

@@ -220,6 +220,14 @@ export function setRiskRuleAssetEnabled(assetId, enabled, options = {}) {
})
}
export function setRiskRuleAssetLevel(assetId, riskLevel, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-level`, {
method: 'POST',
body: JSON.stringify({ risk_level: riskLevel }),
headers: buildWriteHeaders(options)
})
}
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
return apiRequest(
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`

View File

@@ -1,8 +1,9 @@
import { apiRequest } from './api.js'
export function fetchOntologyParse(payload) {
export function fetchOntologyParse(payload, options = {}) {
return apiRequest('/ontology/parse', {
method: 'POST',
body: JSON.stringify(payload)
body: JSON.stringify(payload),
...options
})
}

View File

@@ -1,23 +1,20 @@
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'documents',
'requests',
'approval',
'archive',
'policies',
'audit',
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'documents',
'budget',
'policies',
'audit',
'logs',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver', 'finance', 'executive'],
archive: ['finance', 'executive', 'auditor'],
audit: ['auditor', 'finance'],
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
budget: ['finance', 'executive'],
audit: ['auditor', 'finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
@@ -48,18 +45,22 @@ export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canReturnExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canDeleteArchivedExpenseClaims(user) {
return Boolean(user?.isAdmin)
}
export function canReturnExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
@@ -72,14 +73,18 @@ export function canApproveLeaderExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (isManagerUser(user)) {
return true
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
return false
}
if (isManagerUser(user)) {
return true
}
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true

View File

@@ -0,0 +1,105 @@
function normalizeText(value) {
return String(value || '').trim()
}
function isEmailLike(value) {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
}
function resolveDisplayName(...values) {
for (const value of values) {
const normalized = normalizeText(value)
if (normalized && !isEmailLike(normalized)) {
return normalized
}
}
return ''
}
function toDate(value) {
if (!value) {
return null
}
const nextDate = new Date(value)
return Number.isNaN(nextDate.getTime()) ? null : nextDate
}
function formatDateTime(value) {
const date = toDate(value)
if (!date) {
return ''
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function getRiskFlags(request) {
const flags = request?.riskFlags || request?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
}
function getLatestEvent(events) {
const sortedEvents = events
.filter((item) => item && typeof item === 'object')
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
.filter((item) => item.eventDate)
.sort((left, right) => left.eventDate.getTime() - right.eventDate.getTime())
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
}
export function findLeaderApprovalEvent(request) {
return getLatestEvent(
getRiskFlags(request).filter((flag) => {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
return (
source === 'manual_approval'
&& (
eventType === 'expense_application_approval'
|| previousStage.includes('直属领导')
|| previousStage.includes('领导审批')
|| nextStage.includes('财务')
|| nextStage.includes('审批完成')
)
)
})
)
}
export function buildLeaderApprovalInfo(request) {
const event = findLeaderApprovalEvent(request)
if (!event) {
return {
opinion: '',
operator: '',
time: '',
generatedDraftClaimNo: ''
}
}
return {
opinion: normalizeText(event.opinion) || normalizeText(event.message),
operator: resolveDisplayName(
event.operator,
event.operator_name,
event.operatorName,
request?.profileManager,
request?.managerName
),
time: formatDateTime(event.created_at || event.createdAt),
generatedDraftClaimNo: normalizeText(event.generated_draft_claim_no || event.generatedDraftClaimNo)
}
}
export function resolveGeneratedDraftClaimNo(responsePayload) {
const event = findLeaderApprovalEvent({
riskFlags: responsePayload?.risk_flags_json || responsePayload?.riskFlags || []
})
return normalizeText(event?.generated_draft_claim_no || event?.generatedDraftClaimNo)
}

View File

@@ -5,7 +5,7 @@ const APPLICATION_FIELD_PREFILLS = {
reason: '事由:',
days: '天数:',
transport_mode: '出行方式:',
amount: '预计总费用:'
amount: '用户预估费用:'
}
export function resolveSuggestedActionPrefill(action = {}) {

View File

@@ -34,6 +34,7 @@ function isApplicationDocumentRequest(request) {
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')

View File

@@ -1,5 +1,6 @@
const STORAGE_KEY = 'x-financial.documents.viewed'
const SCOPE_STORAGE_KEY = 'x-financial.documents.scope'
export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT = 'x-financial.documents.viewed-change'
function getStorage() {
return typeof window === 'undefined' ? null : window.localStorage
@@ -30,6 +31,10 @@ export function writeViewedDocumentKeys(keys, storage = getStorage()) {
}
storage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys).filter(Boolean)))
if (typeof window !== 'undefined' && storage === window.localStorage) {
window.dispatchEvent(new CustomEvent(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT))
}
}
export function readDocumentScope(fallback, allowedScopes = [], storage = getStorage()) {

View File

@@ -0,0 +1,47 @@
import { isApplicationRequestLike } from './documentClassification.js'
const ARCHIVED_CLAIM_STATUSES = new Set(['approved', 'completed', 'paid'])
function isArchivedRequestPayload(request) {
if (!request) {
return false
}
const normalizedStatus = String(request.status || '').trim().toLowerCase()
const stage = String(request.approval_stage || request.approvalStage || '').trim()
if (stage === '归档入账' || stage === 'completed') {
return true
}
if (stage.includes('归档') || stage.includes('入账')) {
return true
}
if (
isApplicationRequestLike(request)
&& ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
&& ['审批完成', '申请归档'].includes(stage)
) {
return true
}
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
&& (stage === '' || stage === '归档入账' || stage === 'completed')
}
export function isArchivedDocumentRow(row) {
if (!row) {
return false
}
if (row.archived === true) {
return true
}
return isArchivedRequestPayload(row.rawRequest || row)
}
export function excludeArchivedDocumentRows(rows) {
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
}

View File

@@ -0,0 +1,22 @@
export function isApplicationRequestLike(value) {
const explicitType = String(
value?.documentTypeCode
|| value?.document_type_code
|| value?.documentType
|| value?.document_type
|| ''
).trim()
const claimNo = String(value?.claim_no || value?.claimNo || value?.documentNo || value?.id || '')
.trim()
.toUpperCase()
const typeCode = String(value?.typeCode || value?.expense_type || value?.expenseType || '').trim()
return (
explicitType === 'application'
|| explicitType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')
)
}

View File

@@ -0,0 +1,106 @@
function normalizeText(value) {
return String(value || '').trim()
}
function isProvided(value) {
const text = normalizeText(value)
return Boolean(text) && !['待补充', '暂无', '无', '未知'].includes(text)
}
function resolveApplicationDetailPayload(request = {}) {
const flags = Array.isArray(request.risk_flags_json)
? request.risk_flags_json
: Array.isArray(request.riskFlags)
? request.riskFlags
: []
const detailFlag = flags.find((flag) =>
flag &&
typeof flag === 'object' &&
normalizeText(flag.source) === 'application_detail'
)
const detail = detailFlag?.application_detail || detailFlag?.applicationDetail || {}
return detail && typeof detail === 'object' ? detail : {}
}
function pickDetailValue(detail, request, keys = [], fallback = '') {
for (const key of keys) {
const value = normalizeText(detail[key] ?? request[key])
if (isProvided(value)) return value
}
return normalizeText(fallback)
}
export function buildApplicationDetailFactItems(request = {}) {
const detail = resolveApplicationDetailPayload(request)
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
const rows = [
{
key: 'application_type',
label: '申请类型',
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
},
{
key: 'time',
label: '发生时间',
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
},
{
key: 'location',
label: '地点',
value: pickDetailValue(detail, request, ['location', 'sceneTarget', 'city'], request.sceneTarget)
},
{
key: 'reason',
label: '事由',
value: pickDetailValue(detail, request, ['reason'], request.reason)
},
{
key: 'days',
label: '天数',
value: pickDetailValue(detail, request, ['days'])
},
{
key: 'transport_mode',
label: '出行方式',
value: pickDetailValue(detail, request, ['transport_mode'])
},
{
key: 'grade',
label: '职级',
value: pickDetailValue(detail, request, ['grade', 'profileGrade', 'employee_grade'], request.profileGrade),
highlight: true
},
{
key: 'lodging_daily_cap',
label: '住宿上限/天',
value: pickDetailValue(detail, request, ['lodging_daily_cap']),
highlight: true
},
{
key: 'subsidy_daily_cap',
label: '补贴标准/天',
value: pickDetailValue(detail, request, ['subsidy_daily_cap']),
highlight: true
},
{
key: 'transport_policy',
label: '交通费用口径',
value: pickDetailValue(detail, request, ['transport_policy'], '车票、机票暂无实时价格接口,按真实票据实报实销')
},
{
key: 'policy_estimate',
label: '规则测算参考',
value: pickDetailValue(detail, request, ['policy_estimate']),
highlight: true
},
{
key: 'amount',
label: '用户预估费用',
value: pickDetailValue(detail, request, ['amount'], amountDisplay),
highlight: true,
emphasis: true
}
]
return rows.filter((row) => isProvided(row.value))
}

View File

@@ -47,6 +47,8 @@ const PROMPT_FIELD_LABELS = [
'出行方式',
'交通方式',
'交通工具',
'用户预估费用',
'预估费用',
'预计总费用',
'预计费用',
'预计金额',
@@ -186,10 +188,18 @@ export function expandApplicationTimeWithDays(timeText, days = 0) {
return `${formatApplicationDate(startDate)}${formatApplicationDate(endDate)}`
}
function normalizeApplicationTimeCandidate(value) {
const text = String(value || '').trim().replace(/^[,、。;;\s]+/, '')
if (!text) return ''
if (/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/.test(text)) return text
if (/今天|明天|后天|本周|下周|上周|本月|下月|月底|月初/.test(text)) return text
return ''
}
export function resolveApplicationTimeRange(ontology, prompt) {
const range = ontology?.time_range || {}
const baseTime = resolveTimeRangeText(ontology)
|| resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间'])
const baseTime = normalizeApplicationTimeCandidate(resolveTimeRangeText(ontology))
|| normalizeApplicationTimeCandidate(resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间']))
if (range.start_date && range.end_date && range.start_date !== range.end_date) {
return `${range.start_date}${range.end_date}`
}
@@ -220,9 +230,94 @@ export function resolvePromptField(prompt, labels = []) {
return match ? match[1].trim().replace(/[,。;;]$/, '') : ''
}
export function resolveApplicationReason(prompt) {
function normalizeApplicationTransportMode(value) {
const text = String(value || '').trim()
if (/飞机|机票|航班/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return text
}
function cleanupApplicationReasonCandidate(value, location = '') {
let text = String(value || '').trim()
if (!text) return ''
text = text
.replace(/^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[:]\s*/u, '')
.replace(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '')
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
.replace(/(?:高铁|动车|火车|铁路|列车|飞机|机票|航班|轮船|船票|客轮|渡轮|邮轮)/gu, '')
.replace(/[,、。;;]+/g, '')
.replace(/^\s*(申请|费用申请|业务|本次|去|到|前往|赴)\s*/u, '')
.replace(/^[\s]+|[\s]+$/g, '')
.trim()
const normalizedLocation = String(location || '').trim()
if (normalizedLocation) {
const escapedLocation = escapeRegExp(normalizedLocation)
text = text
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.replace(new RegExp(`^(?:去|到|前往|赴)${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.trim()
}
if (!text) return ''
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) {
return ''
}
return text
}
function resolveApplicationLocationText(ontology, prompt) {
const locationEntity = resolveEntity(ontology, 'location')
return locationEntity?.normalized_value
|| locationEntity?.value
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点', '目的地'])
|| ''
}
export function resolveApplicationReason(prompt, ontology = null) {
const location = resolveApplicationLocationText(ontology, prompt)
const reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason')
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
if (entityReason) {
return cleanupApplicationReasonCandidate(entityReason, location) || entityReason
}
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
return labeled || String(prompt || '').trim()
if (labeled) {
return cleanupApplicationReasonCandidate(labeled, location) || labeled
}
const candidates = String(prompt || '')
.split(/[\n;]+/u)
.map((item) => cleanupApplicationReasonCandidate(item, location))
.filter(Boolean)
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || ''
}
function resolveApplicationTransportMode(ontology, prompt) {
const transportEntity = resolveEntity(ontology, 'transport_mode')
|| resolveEntity(ontology, 'transport')
const fromEntity = normalizeApplicationTransportMode(
transportEntity?.normalized_value || transportEntity?.value || ''
)
if (fromEntity) return fromEntity
const labeled = resolvePromptField(prompt, ['出行方式', '交通方式', '交通工具'])
const fromLabel = normalizeApplicationTransportMode(labeled)
if (fromLabel) return fromLabel
const text = String(prompt || '')
if (/飞机|机票|航班/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return ''
}
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
@@ -260,17 +355,16 @@ export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
const expenseTypeCode = resolveExpenseTypeCode(ontology)
const amount = resolveApplicationAmount(ontology)
const locationEntity = resolveEntity(ontology, 'location')
const documentTypeEntity = resolveEntity(ontology, 'document_type')
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
const timeRange = resolveApplicationTimeRange(ontology, prompt)
|| '待补充'
const location = locationEntity?.normalized_value
|| locationEntity?.value
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点'])
const location = resolveApplicationLocationText(ontology, prompt)
|| '待补充'
const reason = resolveApplicationReason(prompt) || '待补充'
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
const days = resolvePromptDays(prompt)
const transportMode = resolveApplicationTransportMode(ontology, prompt)
const fields = {
documentType: documentTypeEntity?.normalized_value || 'expense_application',
@@ -284,6 +378,8 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
timeRange,
location,
reason,
days: days ? `${days}` : '',
transportMode,
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充',
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),

View File

@@ -0,0 +1,528 @@
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
const APPLICATION_SESSION_TYPE = 'application'
const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'applicationType', label: '申请类型' },
{ key: 'grade', label: '职级', highlight: true },
{ key: 'time', label: '发生时间' },
{ key: 'location', label: '地点' },
{ key: 'reason', label: '事由' },
{ key: 'days', label: '天数' },
{ key: 'transportMode', label: '出行方式' },
{ key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
{ key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
{ key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
{ key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
{ key: 'amount', label: '用户预估费用', highlight: true }
]
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '车票、机票暂无实时价格接口,按真实票据实报实销'
function compactText(value) {
return String(value || '').replace(/\s+/g, '')
}
function resolveFirstMatch(text, patterns = []) {
for (const pattern of patterns) {
const match = text.match(pattern)
const value = String(match?.groups?.value || match?.[1] || '').trim()
if (value) return value.replace(/[,。;;]$/, '')
}
return ''
}
function normalizeDateText(value) {
return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
}
function parseIsoDate(value) {
const match = normalizeDateText(value).match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
if (!match) return null
const [, year, month, day] = match
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
return Number.isNaN(date.getTime()) ? null : date
}
function formatIsoDate(date) {
return date.toISOString().slice(0, 10)
}
function buildEndDateFromDays(startText, daysText = '') {
const days = Number(String(daysText || '').replace(/[^\d]/g, ''))
const start = parseIsoDate(startText)
if (!days || !start) return ''
const end = new Date(start.getTime())
end.setUTCDate(end.getUTCDate() + days)
return formatIsoDate(end)
}
function resolveDaysFromDateRange(rangeText) {
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
if (!match) return ''
const start = parseIsoDate(match[1])
const end = parseIsoDate(match[2])
if (!start || !end) return ''
const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
return diffDays > 0 ? `${diffDays}` : '1天'
}
function resolveApplicationType(text) {
const compact = compactText(text)
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
if (/培训|课程|学习/.test(compact)) return '培训费用申请'
return '费用申请'
}
function resolveApplicationAmount(text) {
const compact = compactText(text)
const labeled = resolveFirstMatch(text, [
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[:]?\s*(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)?/u,
/(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)/u
])
if (labeled) return `${labeled}`
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
return ''
}
function resolveCurrentUserGrade(currentUser = {}) {
return String(
currentUser.grade
|| currentUser.employeeGrade
|| currentUser.employee_grade
|| currentUser.profileGrade
|| ''
).trim()
}
function parseApplicationDaysValue(value) {
const match = String(value || '').match(/\d+/)
const days = match ? Number(match[0]) : 0
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
}
function parseMoneyNumber(value) {
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
const amount = Number(normalized)
return Number.isFinite(amount) ? amount : null
}
function formatPolicyMoney(value) {
const amount = parseMoneyNumber(value)
if (amount === null) return String(value || '').trim()
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount)
}
function formatDailyPolicyMoney(value) {
const display = formatPolicyMoney(value)
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
}
function buildTransportPolicyText(transportMode) {
const mode = String(transportMode || '').trim()
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${mode}票据暂无实时价格接口,按真实票据实报实销`
}
function ensureApplicationPolicyFields(fields = {}) {
const nextFields = { ...fields }
if (!String(nextFields.lodgingDailyCap || '').trim()) {
nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
}
if (!String(nextFields.subsidyDailyCap || '').trim()) {
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
}
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode)
}
if (!String(nextFields.policyEstimate || '').trim()) {
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
}
return nextFields
}
function resolveApplicationDays(text) {
const value = resolveFirstMatch(text, [
/(?:出差|申请)?(?<value>\d+)\s*天/u,
/(?<value>\d+)\s*(?:个)?工作日/u
])
return value ? `${value}` : ''
}
function resolveApplicationTime(text, daysText = '') {
const range = text.match(
/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—||--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
)
if (range) {
return `${normalizeDateText(range[1])}${normalizeDateText(range[2])}`
}
const single = resolveFirstMatch(text, [
/(?:发生时间|业务发生时间|申请时间|时间)\s*[:]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
])
if (!single) return ''
const normalized = normalizeDateText(single)
const endDate = buildEndDateFromDays(normalized, daysText)
return endDate ? `${normalized}${endDate}` : normalized
}
function resolveApplicationLocation(text) {
return resolveFirstMatch(text, [
/(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?<value>[^。;;\n]+)/u,
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
])
}
function resolveApplicationTransportMode(text) {
const compact = compactText(text)
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
if (/飞机|机票|航班/.test(compact)) return '飞机'
if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
return ''
}
function stripKnownContextFromReason(value, context = {}) {
const location = String(context.location || '').trim()
let cleaned = String(value || '')
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[:]\s*(?=[,、。;;\s]|$)/gu, '')
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?=[,、。;;\s]|$)/gu, '')
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—||--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
.replace(/(?:出差|申请)?\d+\s*天/gu, '')
.replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[:]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
.replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
.replace(/[,、。;;]+/g, '')
.replace(/^\s*(去|到|前往)/u, '')
.replace(/^[\s]+|[\s]+$/g, '')
.trim()
if (location) {
const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
cleaned = cleaned
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.trim()
}
return cleaned
}
function pickBusinessReasonSegment(text) {
const segments = String(text || '')
.split(/[,、。;;\n]+/u)
.map((item) => item.trim())
.filter(Boolean)
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
}
function resolveApplicationReason(text, context = {}) {
const labeled = resolveFirstMatch(text, [
/(?:事由|申请事由|出差事由|原因|用途)\s*[:]\s*(?<value>[^,。;;\n]+)/u
])
if (labeled) return stripKnownContextFromReason(labeled, context)
const cleaned = String(text || '')
.replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
const withoutContext = stripKnownContextFromReason(cleaned, context)
const businessSegment = pickBusinessReasonSegment(withoutContext)
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
return withoutContext
}
function isApplicationPreviewValueProvided(value) {
const normalized = String(value || '').trim()
return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
}
function resolveProvidedValue(value, fallback = '') {
return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
}
function normalizeApplicationTypeLabel(value, fallback = '') {
const label = String(value || '').trim()
if (!label || label === '其他费用') return fallback || '费用申请'
if (label.endsWith('费用申请') || label.endsWith('申请')) return label
if (label.endsWith('费用')) return `${label}申请`
if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
return `${label}申请`
}
function normalizeTransportModeOption(value, fallback = '') {
const text = String(value || '').trim()
if (/飞机|机票|航班/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
}
function normalizeAmountFromOntology(fields = {}, fallback = '') {
const numericAmount = Number(fields.amount || 0)
if (Number.isFinite(numericAmount) && numericAmount > 0) {
return `${numericAmount}`
}
const display = String(fields.amountDisplay || '').trim()
if (display && display !== '待补充') {
const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
return normalized.endsWith('元') ? normalized : `${normalized}`
}
return fallback
}
function buildMissingFields(fields) {
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
.filter((item) => item.key !== 'applicationType' && item.required !== false)
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
.map((item) => item.label)
}
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const days = parseApplicationDaysValue(fields.days)
const location = String(fields.location || '').trim()
const grade = String(fields.grade || resolveCurrentUserGrade(currentUser)).trim()
const applicationType = String(fields.applicationType || '').trim()
const transportMode = String(fields.transportMode || '').trim()
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
if (!shouldEstimate || !days || !location) {
return {
canCalculate: false,
reason: '缺少地点或天数',
payload: null
}
}
return {
canCalculate: true,
reason: '',
payload: {
days,
location,
grade
}
}
}
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
const fields = { ...(preview?.fields || {}) }
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
const hotelRate = formatPolicyMoney(result?.hotel_rate)
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
const totalAmount = formatPolicyMoney(result?.total_amount)
const matchedCity = String(result?.matched_city || fields.location || '').trim()
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
grade,
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: buildTransportPolicyText(fields.transportMode),
policyEstimate: `住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天,不含交通票据)`,
matchedCity,
ruleName: String(result?.rule_name || '').trim(),
ruleVersion: String(result?.rule_version || '').trim(),
hotelAmount: hotelAmount ? `${hotelAmount}` : '',
allowanceAmount: allowanceAmount ? `${allowanceAmount}` : '',
policyTotalAmount: totalAmount ? `${totalAmount}` : ''
},
policyEstimate: {
...result,
grade,
matchedCity
},
policyEstimateStatus: 'completed'
})
}
export function applyApplicationPolicyEstimateError(preview = {}, error = null, currentUser = {}) {
const fields = { ...(preview?.fields || {}) }
const message = String(error?.message || error || '').trim()
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
grade: fields.grade || resolveCurrentUserGrade(currentUser),
transportPolicy: buildTransportPolicyText(fields.transportMode),
policyEstimate: message ? `规则中心暂未完成测算:${message}` : APPLICATION_POLICY_PENDING_TEXT
},
policyEstimateStatus: message ? 'failed' : 'pending'
})
}
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) return false
const compact = compactText(rawText)
if (!compact || APPLICATION_QUERY_PATTERN.test(compact)) return false
return APPLICATION_CREATE_PATTERN.test(compact)
}
export function normalizeApplicationPreview(preview = {}) {
const fields = ensureApplicationPolicyFields(preview?.fields || {})
const missingFields = buildMissingFields(fields)
return {
...preview,
fields,
missingFields,
readyToSubmit: missingFields.length === 0
}
}
export function buildModelRefinedApplicationPreview(localPreview = {}, ontology = {}, rawText = '', currentUser = {}) {
const currentFields = localPreview?.fields || {}
const ontologyFields = buildApplicationFieldsFromOntology(ontology || {}, rawText, currentUser)
const parseStrategy = String(ontology?.parse_strategy || '').trim()
const refinedFields = {
...currentFields,
applicationType: normalizeApplicationTypeLabel(
ontologyFields.expenseTypeLabel,
currentFields.applicationType
),
time: resolveProvidedValue(ontologyFields.timeRange, currentFields.time),
location: resolveProvidedValue(ontologyFields.location, currentFields.location),
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
transportMode: normalizeTransportModeOption(
ontologyFields.transportMode,
currentFields.transportMode
),
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
department: resolveProvidedValue(ontologyFields.department, currentFields.department)
}
return normalizeApplicationPreview({
...localPreview,
sourceText: String(rawText || localPreview.sourceText || '').trim(),
fields: refinedFields,
modelRefined: true,
parseStrategy,
modelReviewStatus: parseStrategy === 'llm_primary' ? 'completed' : 'fallback'
})
}
export function buildApplicationPreviewRows(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.map((item) => {
const rawValue = fields[item.key]
const value = String(rawValue || '').trim() || '待补充'
return {
...item,
value,
editable: item.editable !== false,
highlight: Boolean(item.highlight),
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
}
})
}
export function buildApplicationPreviewSubmitText(preview = {}) {
const rows = buildApplicationPreviewRows(preview)
return [
'费用申请确认提交',
...rows.map((row) => `${row.label}${row.value}`),
'',
'确认提交'
].join('\n')
}
export function buildLocalApplicationPreview(rawText, currentUser = {}) {
const sourceText = String(rawText || '').trim()
const explicitDays = resolveApplicationDays(sourceText)
const time = resolveApplicationTime(sourceText, explicitDays)
const days = explicitDays || resolveDaysFromDateRange(time)
const location = resolveApplicationLocation(sourceText)
const fields = {
applicationType: resolveApplicationType(sourceText),
time,
location,
reason: resolveApplicationReason(sourceText, { location }),
days,
transportMode: resolveApplicationTransportMode(sourceText),
amount: resolveApplicationAmount(sourceText),
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
}
return normalizeApplicationPreview({
sourceText,
fields,
modelReviewStatus: 'local'
})
}
export function buildApplicationTemplatePreview(currentUser = {}) {
return normalizeApplicationPreview({
sourceText: '快速发起申请',
fields: {
applicationType: '费用申请',
time: '',
location: '',
reason: '',
days: '',
transportMode: '',
amount: '',
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
},
modelReviewStatus: 'template'
})
}
export function buildLocalApplicationPreviewMessage(preview) {
const normalized = normalizeApplicationPreview(preview)
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
return [
modelReviewStatus === 'completed'
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'
: modelReviewStatus === 'fallback'
? '模型复核没有返回稳定结果,我已先按规则兜底整理成下方表格。请重点核查识别结果;点击对应行即可直接编辑。'
: modelReviewStatus === 'failed'
? '模型复核暂时失败,我先保留一份临时核对表,方便您核查和补充信息。点击对应行即可直接编辑。'
: modelReviewStatus === 'template'
? '我已为你准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。'
: '我先整理出下方表格,请核查识别结果。点击对应行即可直接编辑。'
].join('\n')
}
export function buildApplicationPreviewFooterMessage(preview) {
const normalized = normalizeApplicationPreview(preview)
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
if (missingFields.length) {
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
}
if (modelReviewStatus === 'fallback') {
return '当前结果仅完成规则兜底复核,暂不直接提交。请核查表格内容,或稍后重新发起模型复核。'
}
if (modelReviewStatus === 'failed') {
return '当前结果仅作为临时预览,暂不直接提交。请稍后重试,或补充更明确的信息后再提交。'
}
return '请核对表格信息无误,确认无误后点击 [确认](#application-submit) 提交至审批流程。'
}

View File

@@ -1,3 +1,5 @@
import { isApplicationRequestLike } from './documentClassification.js'
export function isArchivedExpenseClaim(claim) {
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
const status = String(claim?.status || '').trim().toLowerCase()
@@ -10,5 +12,9 @@ export function isArchivedExpenseClaim(claim) {
return false
}
if (isApplicationRequestLike(claim) && ['审批完成', '申请归档'].includes(stage)) {
return true
}
return !stage || stage === '归档入账' || stage === 'completed'
}

View File

@@ -1,3 +1,5 @@
import { isApplicationRequestLike } from './documentClassification.js'
const REQUEST_TYPE_META = {
travel: {
label: '差旅费',
@@ -250,6 +252,29 @@ function resolveDisplayName(...values) {
return ''
}
export function isArchivedRequestView(request) {
const status = String(request?.status || '').trim().toLowerCase()
const approvalKey = String(request?.approvalKey || '').trim().toLowerCase()
const rawStage = String(request?.approval_stage || request?.approvalStage || '').trim()
const displayStage = String(request?.workflowNode || request?.node || '').trim()
const stage = rawStage || displayStage
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档') || stage.includes('入账')) {
return true
}
if (
isApplicationRequestLike(request)
&& ['approved', 'completed', 'paid'].includes(status)
&& ['审批完成', '申请归档'].includes(stage)
) {
return true
}
if (['approved', 'completed', 'paid'].includes(status)) {
return rawStage === '' || rawStage === '归档入账' || rawStage === 'completed'
}
return approvalKey === 'completed'
}
export function normalizeRequestForUi(request) {
if (!request) {
return null

View File

@@ -18,13 +18,11 @@
<main
class="main"
:class="{
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'archive-main': activeView === 'archive',
'policies-main': activeView === 'policies',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'budget-main': activeView === 'budget',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
'logs-main': activeView === 'logs',
@@ -57,7 +55,7 @@
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -68,11 +66,9 @@
<section
class="workarea"
:class="{
'requests-workarea': activeView === 'requests',
'documents-workarea': activeView === 'documents',
'approval-workarea': activeView === 'approval',
'archive-workarea': activeView === 'archive',
'policies-workarea': activeView === 'policies',
'documents-workarea': activeView === 'documents',
'budget-workarea': activeView === 'budget',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'logs-workarea': activeView === 'logs',
'employees-workarea': activeView === 'employees',
@@ -92,11 +88,11 @@
@open-assistant="openSmartEntry"
/>
<TravelRequestDetailView
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
:request="selectedRequest"
:back-label="activeView === 'documents' ? '返回单据中心' : '返回报销列表'"
@back-to-requests="closeRequestDetail"
<TravelRequestDetailView
v-else-if="activeView === 'documents' && detailMode && selectedRequest"
:request="selectedRequest"
back-label="返回单据中心"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated"
@request-deleted="handleRequestDeleted"
@@ -115,22 +111,8 @@
@summary-change="documentSummary = $event"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@reload="reloadRequests"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<ArchiveCenterView v-else-if="activeView === 'archive'" />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<BudgetCenterView v-else-if="activeView === 'budget'" />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
@@ -145,11 +127,12 @@
:initial-prompt="smartEntryContext.prompt"
:initial-files="smartEntryContext.files"
:initial-conversation="smartEntryContext.conversation"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
:reopen-token="smartEntryRevealToken"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
/>
</div>
</template>
@@ -162,13 +145,11 @@ import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import ArchiveCenterView from './ArchiveCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import BudgetCenterView from './BudgetCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import LogsView from './LogsView.vue'
import LogDetailView from './LogDetailView.vue'
@@ -222,9 +203,10 @@ const {
search,
selectedRequest,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
toast,
topBarView
} = useAppShell()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
<template>
<section class="budget-center-page">
<header class="budget-local-head">
<h2>预算管理</h2>
</header>
<section class="budget-summary-grid" aria-label="预算概览">
<article v-for="metric in budgetMetrics" :key="metric.label" class="budget-summary-card">
<span class="summary-icon" :class="metric.tone">
<i :class="metric.icon"></i>
</span>
<div>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<em>{{ metric.note }}</em>
</div>
</article>
</section>
<section class="budget-filter-bar">
<label>
<span>预算周期</span>
<select v-model="filters.period">
<option v-for="period in periods" :key="period">{{ period }}</option>
</select>
</label>
<label>
<span>费用类型</span>
<select v-model="filters.expenseType">
<option v-for="type in expenseTypes" :key="type">{{ type }}</option>
</select>
</label>
<label>
<span>状态</span>
<select v-model="filters.status">
<option v-for="status in statuses" :key="status">{{ status }}</option>
</select>
</label>
<button class="budget-primary-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建预算</span>
</button>
</section>
<section class="budget-work-grid">
<aside class="budget-department-panel">
<header>
<strong>部门切换</strong>
</header>
<div class="department-search">
<i class="mdi mdi-magnify"></i>
<input v-model="departmentKeyword" type="search" placeholder="搜索部门" />
</div>
<nav class="department-list" aria-label="预算部门">
<button
v-for="department in visibleDepartments"
:key="department.code"
type="button"
:class="{ active: department.code === activeDepartmentCode }"
@click="activeDepartmentCode = department.code"
>
<i :class="department.icon"></i>
<span>{{ department.name }}</span>
</button>
</nav>
</aside>
<article class="budget-table-panel">
<header>
<strong>当前部门{{ activeDepartmentName }}</strong>
</header>
<div class="budget-table-wrap">
<table>
<thead>
<tr>
<th>费用类型</th>
<th>预算金额</th>
<th>已发生</th>
<th>已占用</th>
<th>剩余可用</th>
<th>使用率</th>
<th>预警线</th>
<th>控制动作</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleBudgetRows" :key="row.expenseType">
<td>{{ row.expenseType }}</td>
<td>{{ row.total }}</td>
<td>{{ row.used }}</td>
<td>{{ row.occupied }}</td>
<td>{{ row.left }}</td>
<td>
<div class="budget-rate">
<span>{{ row.rate }}%</span>
<div><em :class="row.rateTone" :style="{ width: `${Math.min(row.rate, 100)}%` }"></em></div>
</div>
</td>
<td :class="row.warningTone">{{ row.warningLine }}</td>
<td>{{ row.action }}</td>
<td>
<div class="budget-row-actions">
<button type="button">详情</button>
<button type="button">编辑</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="budget-table-foot">
<button type="button" disabled><i class="mdi mdi-chevron-left"></i></button>
<button type="button" class="active">1</button>
<button type="button" disabled><i class="mdi mdi-chevron-right"></i></button>
<select aria-label="每页条数">
<option>10 /</option>
</select>
<span> {{ visibleBudgetRows.length }} </span>
</footer>
</article>
</section>
<section class="budget-bottom-grid">
<article class="budget-chart-panel">
<header class="budget-card-head">
<strong>预算使用趋势</strong>
<div class="budget-chart-legend">
<span><i class="legend-line budget"></i>预算</span>
<span><i class="legend-line used"></i>已发生</span>
</div>
</header>
<BudgetTrendChart
:labels="trendData.labels"
:budget="trendData.budget"
:used="trendData.used"
/>
</article>
<article class="budget-alert-panel">
<header class="budget-card-head">
<strong>预算预警</strong>
<button type="button">查看全部</button>
</header>
<div class="budget-alert-list">
<div v-for="alert in warnings" :key="alert.title" class="budget-alert-row">
<i :class="alert.tone"></i>
<strong>{{ alert.title }}</strong>
<span>{{ alert.desc }}</span>
<time>{{ alert.date }}</time>
</div>
</div>
</article>
</section>
</section>
</template>
<script src="./scripts/BudgetCenterView.js"></script>
<style scoped src="../assets/styles/views/budget-center-view.css"></style>

View File

@@ -264,6 +264,7 @@ import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, isArchivedDocumentRow } from '../utils/documentCenterRows.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const DOCUMENT_TYPE_ALL = 'all'
@@ -388,9 +389,11 @@ const dateRangeLabel = computed(() => {
})
const ownedRows = computed(() =>
props.filteredRequests
.map((item) => buildDocumentRow(item, { source: 'owned' }))
.filter(Boolean)
excludeArchivedDocumentRows(
props.filteredRequests
.map((item) => buildDocumentRow(item, { source: 'owned' }))
.filter(Boolean)
)
)
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
@@ -518,7 +521,7 @@ const emptyState = computed(() => {
actionIcon: '',
tone: 'emerald',
artLabel: 'APPLY',
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
}
}
@@ -533,10 +536,17 @@ const emptyState = computed(() => {
actionIcon: '',
tone: 'emerald',
artLabel: filtered ? 'FILTER' : 'DOCS',
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档数据']
}
})
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
return '申请归档'
}
return normalized.node || normalized.workflowNode || '财务归档'
}
function buildDocumentRow(request, options = {}) {
const normalized = normalizeRequestForUi(request)
if (!normalized) {
@@ -563,7 +573,7 @@ function buildDocumentRow(request, options = {}) {
documentTypeLabel,
claimId,
documentNo,
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
statusGroup,
statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
@@ -598,6 +608,10 @@ function resolveStatusTone(row, statusGroup) {
}
function matchesStatusTab(row, tab) {
if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
return false
}
if (tab === '全部') return true
if (tab === '草稿') return row.statusGroup === 'draft'
if (tab === '待提交') return row.statusGroup === 'pending_submit'
@@ -730,12 +744,14 @@ async function loadSupportingRows() {
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = Array.isArray(approvalResult.value)
? approvalResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
approvalRows.value = excludeArchivedDocumentRows(
Array.isArray(approvalResult.value)
? approvalResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
)
} else {
approvalRows.value = []
}

View File

@@ -50,7 +50,7 @@
class="assistant-layout"
:class="{
'can-show-insight': hasInsightPanelContent,
'has-insight': showInsightPanel
'has-insight': hasInsightPanelContent && showInsightPanel
}"
>
<section class="dialog-panel">
@@ -109,6 +109,88 @@
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="isApplicationPreviewEditing(message, row.key) && resolveApplicationPreviewEditorControl(row.key) !== 'select'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
@blur="commitApplicationPreviewEditor(message)"
/>
<select
v-else-if="isApplicationPreviewEditing(message, row.key)"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
autofocus
@click.stop
@change="commitApplicationPreviewEditor(message)"
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
@blur="commitApplicationPreviewEditor(message)"
>
<option value="">请选择</option>
<option
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview && buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="renderMarkdown(buildApplicationPreviewFooterText(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
class="welcome-quick-actions"
@@ -577,7 +659,7 @@
</div>
</div>
</div>
<div class="travel-calculator-anchor">
<div v-if="canShowTravelCalculator" class="travel-calculator-anchor">
<button
type="button"
class="tool-btn composer-side-btn travel-calculator-trigger"

View File

@@ -88,7 +88,7 @@
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<article v-if="!isApplicationDocument" class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>附加说明</h3>
@@ -133,11 +133,14 @@
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>{{ isApplicationDocument ? '申请预算' : '费用明细' }}</h3>
<h3 class="detail-card-title-with-icon">
<i v-if="isApplicationDocument" class="mdi mdi-file-document-outline"></i>
<span>{{ isApplicationDocument ? '申请详情' : '费用明细' }}</span>
</h3>
<p>
{{
isApplicationDocument
? '展示本次费用申请的预计金额,提交后纳入预算管理口径。'
? '展示本次申请的事实信息、职级规则测算和用户预估费用。'
: isTravelRequest
? '按出行时间逐笔核对票据与差旅规则。'
: '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。'
@@ -162,13 +165,42 @@
</div>
</div>
<div v-if="isApplicationDocument" class="detail-note readonly">
<p>
预计总费用{{ request.amountDisplay }}该金额用于领导审批和预算管理无需补充任何报销票据
</p>
<div v-if="isApplicationDocument" class="application-detail-facts">
<div
v-for="item in applicationDetailFactItems"
:key="item.key"
class="application-detail-fact"
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-else class="detail-expense-table">
<div v-if="showApplicationLeaderOpinion" class="application-leader-opinion">
<div class="application-leader-opinion-head">
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
</div>
<div v-if="showApplicationLeaderOpinionInput" class="leader-approval-card inline-leader-opinion">
<textarea
v-model="leaderOpinion"
maxlength="500"
:required="requiresApprovalOpinion"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</div>
<div v-else class="detail-note readonly application-leader-opinion-display">
<p>{{ leaderApprovalReadonlyText }}</p>
</div>
</div>
<div v-if="!isApplicationDocument" class="detail-expense-table">
<table>
<thead>
<tr>
@@ -452,6 +484,7 @@
<textarea
v-model="leaderOpinion"
maxlength="500"
:required="requiresApprovalOpinion"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
@@ -479,7 +512,7 @@
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canReturnRequest || canApproveRequest || canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<div v-else-if="canReturnRequest || canApproveRequest || canDeleteRequest" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
@@ -498,10 +531,10 @@
@click="handleApproveRequest"
>
<i class="mdi mdi-check-circle-outline"></i>
{{ approveBusy ? '通过中' : '审批通过' }}
{{ approveBusy ? approveBusyLabel : approveActionLabel }}
</button>
<button
v-if="canManageCurrentClaim"
v-if="canDeleteRequest"
class="reject-action"
type="button"
:disabled="actionBusy"
@@ -750,11 +783,11 @@
:open="approveConfirmDialogOpen"
:badge="approvalConfirmBadge"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
:title="approveConfirmTitle"
:description="approvalConfirmDescription"
cancel-text="返回核对"
confirm-text="确认通过"
busy-text="通过中..."
:confirm-text="approveConfirmText"
:busy-text="approveBusyText"
confirm-tone="primary"
confirm-icon="mdi mdi-check-circle-outline"
:busy="approveBusy"
@@ -771,7 +804,7 @@
<strong>{{ request.node }}</strong>
</div>
<div class="submit-confirm-row">
<span>下一节点</span>
<span>{{ approvalConfirmSummaryLabel }}</span>
<strong>{{ approvalNextStage }}</strong>
</div>
<div class="submit-confirm-row">
@@ -784,7 +817,7 @@
<ReturnReasonDialog
:open="returnDialogOpen"
:title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
:description="returnDialogDescription"
:busy="returnBusy"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"

View File

@@ -21,10 +21,13 @@ import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const ARCHIVE_TAB_ALL = '全部归档'
const ARCHIVE_TAB_APPLICATION = '申请归档'
const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'
const ARCHIVE_TYPE_APPLICATION = '申请'
const ARCHIVE_TYPE_APPLICATION_CODE = 'application'
const ARCHIVE_TYPE_REIMBURSEMENT = '报销'
const ARCHIVE_TYPE_REIMBURSEMENT_CODE = 'reimbursement'
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT]
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_APPLICATION, ARCHIVE_TAB_REIMBURSEMENT]
const RISK_FILTER_OPTIONS = [
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
{ value: 'has', label: '有风险' },
@@ -49,6 +52,7 @@ function buildArchiveRow(request) {
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
const hasRisk = riskCount > 0
const isApplicationDocument = normalized.documentTypeCode === 'application'
const archiveMonth = extractArchiveMonth(
normalized.updatedAt,
normalized.submittedAt,
@@ -68,16 +72,16 @@ function buildArchiveRow(request) {
archivedAt: normalized.updatedAt || normalized.applyTime,
archiveMonth,
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
archiveType: ARCHIVE_TYPE_REIMBURSEMENT,
archiveTypeCode: ARCHIVE_TYPE_REIMBURSEMENT_CODE,
node: normalized.workflowNode || '归档入账',
archiveType: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT,
archiveTypeCode: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION_CODE : ARCHIVE_TYPE_REIMBURSEMENT_CODE,
node: isApplicationDocument ? '申请归档' : (normalized.workflowNode || '归档入账'),
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: ARCHIVE_TAB_REIMBURSEMENT
archiveTab: isApplicationDocument ? ARCHIVE_TAB_APPLICATION : ARCHIVE_TAB_REIMBURSEMENT
}
}

View File

@@ -69,9 +69,7 @@ import {
} from './auditViewModel.js'
import {
createDefaultRiskRuleForm,
RISK_RULE_CREATE_DOMAIN_OPTIONS,
RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
RISK_RULE_LEVEL_OPTIONS
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
} from './auditViewRiskRuleModel.js'
export default {
@@ -141,6 +139,7 @@ export default {
let spreadsheetOnlyOfficeHadLocalEdits = false
let spreadsheetOnlyOfficeSyncSeq = 0
let spreadsheetOnlyOfficeChangePollTimer = null
const riskRuleGenerationPollTimers = new Map()
const assetBuckets = ref({
financialRules: [],
riskRules: [],
@@ -162,7 +161,7 @@ export default {
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const showOnlineColumn = computed(() => activeType.value === 'riskRules')
const showOnlineColumn = computed(() => false)
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
@@ -188,11 +187,19 @@ export default {
const riskRuleInReview = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
)
const riskRuleGenerationBusy = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'generating'
)
const riskRuleGenerationFailed = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'failed'
)
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
Boolean(selectedSkill.value?.id) &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value &&
!detailBusy.value
)
const canDeleteRiskRule = computed(
@@ -203,11 +210,17 @@ export default {
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
!detailBusy.value
)
const canSubmitRiskRuleReview = computed(
const canOpenRiskRuleReviewSubmit = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canSubmitReview.value &&
!riskRuleInReview.value &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value
)
const canSubmitRiskRuleReview = computed(
() =>
canOpenRiskRuleReviewSubmit.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
@@ -355,8 +368,8 @@ export default {
const showRiskScenarioFilter = computed(() =>
['financialRules', 'riskRules'].includes(activeType.value)
)
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
const showOnlineFilter = computed(() => activeType.value === 'riskRules')
const showStatusFilter = computed(() => true)
const showOnlineFilter = computed(() => false)
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
const selectedRiskScenarioLabel = computed(
() =>
@@ -618,6 +631,11 @@ export default {
return
}
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
const ruleTitle = String(riskRuleCreateForm.value.rule_title || '').trim()
if (ruleTitle.length < 2) {
toast('请输入至少 2 个字的规则标题。')
return
}
if (naturalLanguage.length < 8) {
toast('请至少输入 8 个字的风险规则描述。')
return
@@ -627,11 +645,9 @@ export default {
try {
const detail = await generateRiskRuleAsset(
{
business_domain: riskRuleCreateForm.value.business_domain,
expense_category: riskRuleCreateForm.value.business_domain === 'expense'
? riskRuleCreateForm.value.expense_category
: null,
risk_level: riskRuleCreateForm.value.risk_level,
business_domain: 'expense',
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
natural_language: naturalLanguage
},
@@ -639,9 +655,8 @@ export default {
)
riskRuleCreateOpen.value = false
await refreshCurrentAssets()
selectedSkill.value = buildDetailViewModel(detail, runs.value)
await loadRiskRuleJson(detail.id)
toast('风险规则草稿已生成,请在详情中核对业务说明和判断流程。')
scheduleRiskRuleGenerationPoll(detail.id)
toast('风险规则已进入后台生成,列表会先显示生成中。')
} catch (error) {
toast(error?.message || '风险规则生成失败,请稍后重试。')
} finally {
@@ -649,6 +664,40 @@ export default {
}
}
function stopRiskRuleGenerationPoll(assetId) {
const timer = riskRuleGenerationPollTimers.get(assetId)
if (timer) {
window.clearTimeout(timer)
riskRuleGenerationPollTimers.delete(assetId)
}
}
function scheduleRiskRuleGenerationPoll(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId) {
return
}
stopRiskRuleGenerationPoll(normalizedAssetId)
const timer = window.setTimeout(async () => {
try {
await refreshCurrentAssets()
const latest = (assetBuckets.value.riskRules || []).find((item) => item.id === normalizedAssetId)
if (!latest || latest.statusValue !== 'generating' || attempt >= 59) {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
return
}
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} catch {
if (attempt < 59) {
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} else {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
}
}
}, attempt === 0 ? 1200 : 3000)
riskRuleGenerationPollTimers.set(normalizedAssetId, timer)
}
async function persistRuleRuntimeConfig(asset, runtimeRule) {
await updateAgentAsset(
asset.id,
@@ -1033,6 +1082,9 @@ export default {
loadSpreadsheetChangeRecords(assetId).catch(() => {})
}
if (selectedSkill.value.usesJsonRiskRule) {
if (selectedSkill.value.riskRuleGenerationFailed || selectedSkill.value.riskRuleGenerationBusy) {
return
}
try {
await loadRiskRuleJson(assetId)
} catch (jsonError) {
@@ -1143,6 +1195,10 @@ export default {
}
function openAssetDetail(asset) {
if (asset?.usesJsonRiskRule && asset.statusValue === 'generating') {
toast('规则仍在后台生成中,生成完成后才能进入详情。')
return
}
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
@@ -1397,11 +1453,13 @@ export default {
}
async function openSubmitReviewDialog() {
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
if (
selectedSkillUsesJsonRisk.value &&
!canOpenRiskRuleReviewSubmit.value
) {
return
}
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
toast('请先在“测试规则”中保存测试通过报告,再提交审核。')
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || ''
@@ -1698,6 +1756,8 @@ export default {
onBeforeUnmount(() => {
destroySpreadsheetOnlyOfficeEditor()
riskRuleGenerationPollTimers.forEach((timer) => window.clearTimeout(timer))
riskRuleGenerationPollTimers.clear()
document.removeEventListener('click', handleDocumentClick)
})
@@ -1753,6 +1813,7 @@ export default {
canCreateRiskRule,
canOpenRiskRuleTest,
canDeleteRiskRule,
canOpenRiskRuleReviewSubmit,
canSubmitRiskRuleReview,
canReturnRiskRule,
canPublishRiskRule,
@@ -1790,9 +1851,7 @@ export default {
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
showReviewNote,
spreadsheetUploadInput,
spreadsheetOnlyOfficeLoading,

View File

@@ -0,0 +1,216 @@
import { computed, onMounted, ref } from 'vue'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
{ code: 'FINANCE-DEPT', name: '财务部', costCenter: 'CC-2100' },
{ code: 'TECH-DEPT', name: '技术部', costCenter: 'CC-6100' },
{ code: 'HR-DEPT', name: '人力资源部', costCenter: 'CC-3200' },
{ code: 'PRODUCTION-DEPT', name: '生产部', costCenter: 'CC-7200' },
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
const EXPENSE_BLUEPRINTS = [
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
]
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
function buildDepartmentRows(departmentCode) {
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
const factor = 0.88 + (seed % 18) / 100
return EXPENSE_BLUEPRINTS.map((item, index) => {
const totalAmount = Math.round(item.total * factor)
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
return {
...item,
totalAmount,
usedAmount,
occupiedAmount,
leftAmount,
rate,
rateTone: rate >= item.warning ? 'danger' : rate >= item.warning - 12 ? 'warn' : 'ok',
warningTone: item.warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
warningLine: `${item.warning}%`,
total: currency(totalAmount),
used: currency(usedAmount),
occupied: currency(occupiedAmount),
left: currency(leftAmount)
}
})
}
function buildTrendData(rows) {
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
const used = rows.reduce((sum, item) => sum + item.usedAmount + item.occupiedAmount, 0)
return {
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
budget: [0.05, 0.18, 0.25, 0.34, 0.45, 0.52, 0.68, 0.76, 0.84, 0.91, 0.96, 1].map((ratio) =>
Math.round(total * ratio)
),
used: [0.03, 0.1, 0.13, 0.22, 0.3, 0.37, 0.51, 0.59, 0.69, 0.73, 0.86, 0.96].map((ratio) =>
Math.round(used * ratio)
)
}
}
export default {
name: 'BudgetCenterView',
components: {
BudgetTrendChart
},
setup() {
const departments = ref(FALLBACK_DEPARTMENTS)
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
const filters = ref({
period: '2026年度',
expenseType: '全部',
status: '全部'
})
const activeDepartment = computed(() =>
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
)
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
const departmentRows = computed(() => buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value))
const visibleBudgetRows = computed(() =>
departmentRows.value
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
if (filters.value.status === '全部') return true
if (filters.value.status === '预警') return row.rateTone === 'warn'
if (filters.value.status === '管控') return row.rateTone === 'danger'
return row.rateTone === 'ok'
})
)
const totals = computed(() => {
const rows = departmentRows.value
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
const used = rows.reduce((sum, item) => sum + item.usedAmount, 0)
const occupied = rows.reduce((sum, item) => sum + item.occupiedAmount, 0)
return {
total,
used,
occupied,
left: Math.max(total - used - occupied, 0)
}
})
const budgetMetrics = computed(() => [
{
label: '预算总额',
value: `¥${currency(totals.value.total)}`,
note: '本年累计',
tone: 'green',
icon: 'mdi mdi-wallet-outline'
},
{
label: '已发生',
value: `¥${currency(totals.value.used)}`,
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
tone: 'blue',
icon: 'mdi mdi-chart-line'
},
{
label: '已占用',
value: `¥${currency(totals.value.occupied)}`,
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
tone: 'orange',
icon: 'mdi mdi-briefcase-check-outline'
},
{
label: '剩余可用',
value: `¥${currency(totals.value.left)}`,
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
tone: 'green',
icon: 'mdi mdi-currency-cny'
}
])
const visibleDepartments = computed(() => {
const keyword = departmentKeyword.value.trim()
return departments.value
.filter((item) => !keyword || item.name.includes(keyword) || item.code.includes(keyword))
.map((item) => ({
...item,
icon: item.code === activeDepartmentCode.value ? 'mdi mdi-account-group-outline' : 'mdi mdi-domain'
}))
})
const warnings = computed(() =>
departmentRows.value
.slice()
.sort((a, b) => b.rate - a.rate)
.slice(0, 4)
.map((row, index) => ({
title: row.expenseType,
desc: `使用率已达 ${row.rate}%${row.rate >= row.warning ? '已超过预警线' : '接近预警线'}${row.warningLine}`,
date: index < 2 ? '2026-05-12' : '2026-05-10',
tone: row.rate >= row.warning ? 'danger' : row.rate >= row.warning - 12 ? 'warn' : 'ok'
}))
)
const trendData = computed(() => buildTrendData(departmentRows.value))
async function loadDepartments() {
try {
const payload = await fetchEmployeeMeta()
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
const nextDepartments = options
.filter((item) => item?.code && item?.name)
.map((item) => ({
code: String(item.code),
name: String(item.name),
costCenter: String(item.costCenter || '')
}))
if (nextDepartments.length) {
departments.value = nextDepartments
if (!nextDepartments.some((item) => item.code === activeDepartmentCode.value)) {
activeDepartmentCode.value = nextDepartments[0].code
}
}
} catch (error) {
console.warn('Failed to load budget departments from employee meta:', error)
}
}
onMounted(() => {
void loadDepartments()
})
return {
activeDepartmentCode,
activeDepartmentName,
budgetMetrics,
departmentKeyword,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
filters,
periods: ['2026年度', '2026年Q2', '2026年5月'],
statuses: ['全部', '正常', '预警', '管控'],
trendData,
visibleBudgetRows,
visibleDepartments,
warnings
}
}
}

View File

@@ -34,7 +34,7 @@ const FALLBACK_ROLE_OPTIONS = [
id: 'approver',
code: 'approver',
label: '审批负责人',
desc: '可以处理审批中心中的待审单据。'
desc: '可以处理单据中心中的待审单据。'
},
{
id: 'executive',

View File

@@ -12,6 +12,7 @@ import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementRevi
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
@@ -34,6 +35,12 @@ import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
calculateTravelReimbursement,
fetchExpenseClaims,
@@ -520,6 +527,10 @@ export default {
invalidatedDraftClaimId: {
type: String,
default: ''
},
reopenToken: {
type: Number,
default: 0
}
},
emits: ['close', 'draft-saved'],
@@ -578,7 +589,22 @@ export default {
const reviewActionBusy = ref(false)
const deleteSessionBusy = ref(false)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const {
applicationPreviewEditor,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
cancelApplicationPreviewEditor,
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast
})
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
@@ -642,9 +668,9 @@ export default {
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(
() => isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
)
const hasInsightPanelContent = computed(() => {
return isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
})
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
@@ -820,7 +846,7 @@ export default {
applyComposerDateSelection,
resolveTravelCalculatorInitialDays,
resolveTravelCalculatorInitialLocation,
openTravelCalculator,
openTravelCalculator: openTravelCalculatorInternal,
toggleTravelCalculator: toggleTravelCalculatorInternal,
closeTravelCalculator,
formatTravelCalculatorMoney,
@@ -845,6 +871,7 @@ export default {
buildLocallySyncedReviewPayload,
formatDateInputValue
})
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
const {
fileInputMode,
attachedFiles,
@@ -940,6 +967,7 @@ export default {
fetchExpenseClaims,
fileInputRef,
flowRunId,
insightPanelCollapsed,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
@@ -1011,13 +1039,30 @@ export default {
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer: submitComposerInternal,
currentUser,
toast
})
function openTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return openTravelCalculatorInternal()
}
function toggleTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return toggleTravelCalculatorInternal()
}
function submitTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
// 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。
// calculateTravelReimbursement({ grade: String(user.grade || '').trim() })
// 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计
@@ -1027,6 +1072,11 @@ export default {
// messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload)
return submitTravelCalculatorInternal()
}
watch(canShowTravelCalculator, (visible) => {
if (!visible && travelCalculatorOpen.value) {
closeTravelCalculator()
}
})
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() =>
@@ -1142,6 +1192,21 @@ export default {
}
)
watch(
() => props.reopenToken,
(token, previousToken) => {
if (token === previousToken) {
return
}
closeAfterBusy.value = false
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
@@ -1510,7 +1575,7 @@ export default {
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-request-detail',
name: 'app-document-detail',
params: { requestId: claimId }
})
: null
@@ -1558,7 +1623,7 @@ export default {
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-request-detail',
name: 'app-document-detail',
params: { requestId: claimId }
})
return {
@@ -1579,6 +1644,9 @@ export default {
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.applicationPreview) {
return 'message-bubble-application-preview'
}
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
return ''
}
@@ -1635,10 +1703,27 @@ export default {
}
}
function buildApplicationPreviewFooterText(message) {
if (!message?.applicationPreview) {
return ''
}
return buildApplicationPreviewFooterMessage(message.applicationPreview)
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
toast(`请先补充:${normalizedPreview.missingFields.join('、')}`)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
@@ -1660,6 +1745,12 @@ export default {
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
applicationSubmitConfirmDialog.value = {
open: false,
message: null
@@ -1667,10 +1758,15 @@ export default {
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: '确认提交',
rawText: applicationSubmitText,
userText: '确认提交',
pendingText: '正在提交费用申请...',
systemGenerated: true
systemGenerated: true,
skipScopeGuard: true,
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
@@ -1708,6 +1804,9 @@ export default {
}
function emitCloseAfterLeave() {
if (workbenchVisible.value) {
return
}
if (closeAfterBusy.value && isWorkbenchBusy()) {
return
}
@@ -1722,7 +1821,7 @@ export default {
}
router.push({
name: 'app-request-detail',
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
@@ -2018,12 +2117,12 @@ export default {
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, applicationSubmitConfirmDialog, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, canShowTravelCalculator, deleteSessionDialogOpen, applicationSubmitConfirmDialog, applicationPreviewEditor, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -20,11 +20,14 @@ import {
} from '../../services/reimbursements.js'
import {
canApproveLeaderExpenseClaims,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isFinanceUser
} from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import { buildLeaderApprovalInfo, resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js'
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
@@ -460,7 +463,13 @@ export default {
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
return isEditableRequest.value || canManageCurrentClaim.value
})
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
@@ -475,7 +484,7 @@ export default {
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
Boolean(props.approvalMode)
(Boolean(props.approvalMode) || isApplicationDocument.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
@@ -490,7 +499,37 @@ export default {
)
)
)
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
const showApplicationLeaderOpinionInput = computed(() => (
isApplicationDocument.value
&& canApproveRequest.value
&& isDirectManagerApprovalStage.value
))
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalReadonlyText = computed(() => {
if (leaderApprovalInfo.value.opinion) {
return leaderApprovalInfo.value.opinion
}
return isApplicationDocument.value ? '待直属领导填写审批意见。' : ''
})
const leaderApprovalReadonlyMeta = computed(() => {
const pieces = [
leaderApprovalInfo.value.operator ? `${leaderApprovalInfo.value.operator}确认` : '',
leaderApprovalInfo.value.time
].filter(Boolean)
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
}
return pieces.join(' · ')
})
const showApplicationLeaderOpinion = computed(() => (
isApplicationDocument.value
&& (
showApplicationLeaderOpinionInput.value
|| leaderApprovalReadonlyText.value
)
))
const showLeaderApprovalPanel = computed(() => canApproveRequest.value && !showApplicationLeaderOpinionInput.value)
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) {
@@ -505,7 +544,7 @@ export default {
if (isFinanceApprovalStage.value) {
return '审核通过后将进入归档入账。'
}
return isApplicationDocument.value ? '审批通过后申请流程完成。' : '审批通过后将流转至财务审批。'
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() => {
@@ -513,7 +552,7 @@ export default {
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
}
if (isApplicationDocument.value) {
return '确认后该申请单会完成直属领导审批,请确认申请信息与领导意见无误。'
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。'
}
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
})
@@ -521,14 +560,29 @@ export default {
if (isFinanceApprovalStage.value) {
return '归档入账'
}
return isApplicationDocument.value ? '审批完成' : '财务审批'
return isApplicationDocument.value ? '报销草稿' : '财务审批'
})
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
const approveConfirmTitle = computed(() => (
isApplicationDocument.value ? `确认审核 ${request.value.id} 吗?` : `确认通过 ${request.value.id} 吗?`
))
const approveConfirmText = computed(() => (isApplicationDocument.value ? '确认审核' : '确认通过'))
const approveBusyText = computed(() => (isApplicationDocument.value ? '确认中...' : '通过中...'))
const returnDialogDescription = computed(() => (
isApplicationDocument.value
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
))
const approvalConfirmSummaryLabel = computed(() => (
isApplicationDocument.value ? '生成结果' : '下一节点'
))
const approvalSuccessToast = computed(() => {
if (isFinanceApprovalStage.value) {
return `${request.value.id} 已完成财务终审,进入归档入账。`
}
return isApplicationDocument.value
? `${request.value.id} 申请已审批通过`
? `${request.value.id} 已确认审核,正在生成报销草稿`
: `${request.value.id} 已审批通过,流转至财务审批。`
})
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
@@ -613,13 +667,6 @@ export default {
value: request.value.typeLabel,
icon: '',
valueClass: ''
},
{
key: 'status',
label: '当前状态',
value: request.value.node,
icon: '',
valueClass: 'status'
}
])
@@ -652,6 +699,7 @@ export default {
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
return formatCurrency(total)
})
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseTableColumnCount = computed(
@@ -1582,7 +1630,11 @@ export default {
}
if (!canDeleteRequest.value) {
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
toast(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: '当前单据已进入流程,只有高级管理人员可以删除。'
)
return
}
@@ -1668,6 +1720,11 @@ export default {
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
return
}
approveConfirmDialogOpen.value = true
}
@@ -1692,14 +1749,25 @@ export default {
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
approveConfirmDialogOpen.value = false
return
}
approveBusy.value = true
try {
await approveExpenseClaim(request.value.claimId, {
const responsePayload = await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim()
})
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(approvalSuccessToast.value)
toast(
isApplicationDocument.value && generatedDraftClaimNo
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
: approvalSuccessToast.value
)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
@@ -1736,8 +1804,11 @@ export default {
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
approvalOpinionTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview,
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
applicationDetailFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog,
closeRiskOverrideDialog,
@@ -1756,12 +1827,15 @@ export default {
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
leaderApprovalReadonlyMeta, leaderApprovalReadonlyText,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
showAiAdvicePanel, showApplicationLeaderOpinion, showApplicationLeaderOpinionInput,
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}

View File

@@ -10,8 +10,9 @@ export const RULE_TABLE_COLUMNS = {
export const RISK_RULE_TABLE_COLUMNS = {
...RULE_TABLE_COLUMNS,
owner: '审核人',
metric: '发布者',
updatedAt: '发布时间'
status: '状态',
metric: '创建者',
updatedAt: '创建时间'
}
export const TYPE_META = {
@@ -100,7 +101,7 @@ export const TAB_META = {
tableColumns: RISK_RULE_TABLE_COLUMNS,
showRuntimeColumn: false,
showVersionColumn: false,
showStatusColumn: false,
showStatusColumn: true,
badgeTone: 'rose'
},
skills: {
@@ -121,10 +122,12 @@ export const TAB_META = {
}
export const STATUS_META = {
generating: { label: '生成中', tone: 'info' },
draft: { label: '草稿中', tone: 'draft' },
review: { label: '待审核', tone: 'warning' },
active: { label: '已上线', tone: 'success' },
disabled: { label: '已停用', tone: 'disabled' }
disabled: { label: '已停用', tone: 'disabled' },
failed: { label: '生成失败', tone: 'danger' }
}
export const REVIEW_META = {
@@ -250,10 +253,12 @@ export const DETAIL_TITLES = {
export const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'generating', label: '生成中' },
{ value: 'draft', label: '草稿中' },
{ value: 'review', label: '待审核' },
{ value: 'active', label: '已上线' },
{ value: 'disabled', label: '已停用' }
{ value: 'disabled', label: '已停用' },
{ value: 'failed', label: '生成失败' }
]
export const ONLINE_STATE_OPTIONS = [
@@ -285,6 +290,15 @@ export const RULE_TAB_TAG_ALIASES = {
export const RISK_SCENARIO_OPTIONS = [
{ value: '', label: '全部场景' },
{ value: '差旅费', label: '差旅费' },
{ value: '住宿费', label: '住宿费' },
{ value: '交通费', label: '交通费' },
{ value: '业务招待费', label: '业务招待费' },
{ value: '会务费', label: '会务费' },
{ value: '办公用品费', label: '办公用品费' },
{ value: '培训费', label: '培训费' },
{ value: '通讯费', label: '通讯费' },
{ value: '福利费', label: '福利费' },
{ value: '差旅', label: '差旅' },
{ value: '发票', label: '发票' },
{ value: '餐饮招待', label: '餐饮招待' },

View File

@@ -27,6 +27,10 @@ import {
resolveRiskRuleFields,
resolveRiskRuleFlow,
resolveRiskRuleFlowDiagramSvg,
resolveRiskRuleScore,
resolveRiskRuleScoreDetail,
resolveRiskRuleScoreLabel,
resolveRiskRuleScoreLevel,
resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js'
@@ -327,6 +331,14 @@ export function readScenarioItems(source) {
export function resolveRiskRuleCategory(source) {
const configJson = readConfigJson(source)
const expenseCategoryLabel =
normalizeText(configJson.expense_category_label) ||
normalizeText(configJson.metadata?.expense_category_label) ||
normalizeText(source?.expense_category_label)
if (expenseCategoryLabel) {
return expenseCategoryLabel
}
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
if (explicit) {
return explicit
@@ -442,16 +454,24 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
normalizeText(apiPayload?.description) ||
normalizeText(target.riskRuleDescription)
const riskCategory =
normalizeText(metadata.expense_category_label) ||
normalizeText(apiConfig.expense_category_label) ||
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher = apiPayload?.created_by || target.publisher || (apiPayload?.recent_versions && apiPayload.recent_versions[0]?.created_by) || '系统管理员'
const publisher =
target.creator ||
normalizeText(apiPayload?.owner) ||
normalizeText(metadata.created_by) ||
normalizeText(apiPayload?.recent_versions?.[0]?.created_by) ||
'未知'
let publishedAt = target.publishedAt || '-'
if (apiPayload?.recent_versions) {
@@ -470,15 +490,23 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
riskCategory,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSeverity: resolveRiskRuleSeverity(rulePayload),
riskRuleSeverityLabel: resolveRiskRuleSeverityLabel(rulePayload),
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleSeverityLabel: riskRuleScoreLevel
? resolveRiskRuleScoreLabel(rulePayload, apiConfig)
: resolveRiskRuleSeverityLabel(rulePayload),
riskRuleScore: resolveRiskRuleScore(rulePayload, apiConfig),
riskRuleScoreLabel: resolveRiskRuleScoreLabel(rulePayload, apiConfig),
riskRuleScoreLevel: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleScoreDetail: resolveRiskRuleScoreDetail(rulePayload, apiConfig),
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
riskRuleFields,
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
riskRuleFlowDiagramSvg:
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
riskRuleFlowDiagramSvg: resolveRiskRuleFlowDiagramSvg({
...rulePayload,
flow_diagram_svg: normalizeText(apiPayload?.flow_diagram_svg) || rulePayload?.flow_diagram_svg
}),
riskRuleRequiresAttachment: Boolean(
rulePayload.requires_attachment ||
metadata.requires_attachment ||
@@ -860,12 +888,13 @@ export function buildListItem(asset) {
const isOnlineValue = asset.status === 'active'
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const publisher = isRiskRule
? isOnlineValue
? normalizeText(asset.published_by) || reviewer || modifiedBy || '系统管理员'
: '-'
: ''
const publishedAt = isRiskRule && isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-'
const creator =
normalizeText(asset.owner) ||
normalizeText(asset.config_json?.generation_request?.actor) ||
modifiedBy ||
'未知'
const publisher = isRiskRule ? creator : ''
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
return {
id: asset.id,
@@ -895,8 +924,9 @@ export function buildListItem(asset) {
statusValue: asset.status,
statusTone: statusMeta.tone,
hitRate: isRiskRule ? publisher : buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
creator,
publisher,
publishedAt,
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
isOnlineValue,
isOnlineLabel: isOnlineValue ? '是' : '否',
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
@@ -905,7 +935,7 @@ export function buildListItem(asset) {
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
modifiedBy,
changeCount,
updatedAt: isRiskRule ? publishedAt : formatDateTime(asset.updated_at),
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
domainValue: asset.domain
}
@@ -1283,6 +1313,13 @@ export function buildDetailViewModel(detail, runs) {
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(detail) : true
const generationStatus = normalizeText(configJson.generation_status || detail.status)
const riskRuleGenerationFailed = usesJsonRiskRule && (detail.status === 'failed' || generationStatus === 'failed')
const riskRuleGenerationBusy = usesJsonRiskRule && (detail.status === 'generating' || generationStatus === 'generating')
const riskRuleCreator =
normalizeText(detail.owner) ||
normalizeText(detail.recent_versions?.[0]?.created_by) ||
'未知'
return {
id: detail.id,
@@ -1321,6 +1358,10 @@ export function buildDetailViewModel(detail, runs) {
riskRuleSourceRef: '',
riskRuleSeverity: 'medium',
riskRuleSeverityLabel: '中风险',
riskRuleScore: null,
riskRuleScoreLabel: '待计算',
riskRuleScoreLevel: 'medium',
riskRuleScoreDetail: null,
riskRuleCreatedAt: formatDateTime(detail.created_at),
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
isOnlineLabel: detail.status === 'active' ? '是' : '否',
@@ -1334,7 +1375,8 @@ export function buildDetailViewModel(detail, runs) {
detail.reviewer ||
(detail.recent_versions && detail.recent_versions[0]?.created_by) ||
'系统管理员'
: '-',
: riskRuleCreator,
creator: riskRuleCreator,
publishedAt:
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
@@ -1344,6 +1386,10 @@ export function buildDetailViewModel(detail, runs) {
riskRuleFlow: resolveRiskRuleFlow({}, []),
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
riskRuleRequiresAttachment: Boolean(configJson.requires_attachment),
riskRuleGenerationStatus: generationStatus,
riskRuleGenerationFailed,
riskRuleGenerationBusy,
riskRuleGenerationError: normalizeText(configJson.generation_error),
latestTestSummary: detail.latest_test_summary || detail.latestTestSummary || null,
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
ruleDocument,

View File

@@ -17,22 +17,40 @@ export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
]
export const RISK_RULE_LEVEL_OPTIONS = [
{ value: 'low', label: '低风险' },
{ value: 'medium', label: '中风险' },
{ value: 'high', label: '高风险' },
{ value: 'low', label: '风险' }
{ value: 'critical', label: '极高风险' }
]
const RISK_LEVEL_LABELS = {
low: '低风险',
medium: '中风险',
high: '高风险'
high: '高风险',
critical: '极高风险'
}
const RISK_SCORE_LEVEL_LABELS = RISK_LEVEL_LABELS
const CITY_ROUTE_CONDITION_SUMMARY =
'判断公式A=交通票行程城市住宿发票城市B=申报目的地明细发生地点C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。'
const CITY_ROUTE_FLOW_DECISION =
'附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市'
const CITY_ROUTE_FLOW_EVIDENCE =
'读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由'
const CITY_ROUTE_SEMANTIC_TYPES = new Set([
'travel_city_consistency',
'travel_route_city_consistency'
])
export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
expense_category: 'travel',
risk_level: 'medium',
rule_title: '',
requires_attachment: false,
natural_language: ''
}
@@ -52,16 +70,66 @@ export function formatRiskRuleFieldDisplay(field) {
}
export function resolveRiskRuleSeverity(payload) {
const scoreLevel = resolveRiskRuleScoreLevel(payload)
if (scoreLevel) {
return scoreLevel
}
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
const severity = normalizeRiskRuleText(fail.severity || payload?.severity).toLowerCase()
return ['low', 'medium', 'high'].includes(severity) ? severity : 'medium'
return Object.prototype.hasOwnProperty.call(RISK_LEVEL_LABELS, severity) ? severity : 'medium'
}
export function resolveRiskRuleSeverityLabel(payload) {
return RISK_LEVEL_LABELS[resolveRiskRuleSeverity(payload)] || '中风险'
}
export function resolveRiskRuleScore(payload, fallbackConfig = {}) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
const candidates = [
metadata.risk_score,
payload?.risk_score,
fail.risk_score,
fallbackConfig?.risk_score,
fallbackConfig?.riskScore
]
for (const value of candidates) {
const score = Number(value)
if (Number.isFinite(score)) {
return Math.max(0, Math.min(100, Math.round(score)))
}
}
return null
}
export function resolveRiskRuleScoreLevel(payload, fallbackConfig = {}) {
const score = resolveRiskRuleScore(payload, fallbackConfig)
if (score === null) {
return ''
}
if (score <= 30) return 'low'
if (score <= 60) return 'medium'
if (score <= 80) return 'high'
return 'critical'
}
export function resolveRiskRuleScoreLabel(payload, fallbackConfig = {}) {
const level = resolveRiskRuleScoreLevel(payload, fallbackConfig)
return level ? RISK_SCORE_LEVEL_LABELS[level] : '待计算'
}
export function resolveRiskRuleScoreDetail(payload, fallbackConfig = {}) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const detail =
metadata.risk_score_detail ||
payload?.risk_score_detail ||
fallbackConfig?.risk_score_detail ||
fallbackConfig?.riskScoreDetail
return detail && typeof detail === 'object' ? detail : null
}
export function resolveRiskRuleFields(payload) {
const inputs = payload && typeof payload === 'object' ? payload.inputs || {} : {}
const fieldRows = Array.isArray(inputs.fields) ? inputs.fields : []
@@ -146,15 +214,24 @@ export function resolveRiskRuleBusinessDescription(payload, fallback) {
export function resolveRiskRuleFlowDiagramSvg(payload) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
return (
const svg =
normalizeRiskRuleText(payload?.flow_diagram_svg) ||
normalizeRiskRuleText(metadata.flow_diagram_svg)
)
if (svg && !svg.includes('data-risk-flow-detail="logic-v2"')) {
return ''
}
if (isCityRouteConsistencyPayload(payload) && svg.includes('风险关键词')) {
return ''
}
return svg
}
export function resolveRiskRuleConditionSummary(payload) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const params = payload && typeof payload === 'object' ? payload.params || {} : {}
if (isCityRouteConsistencyPayload(payload)) {
return CITY_ROUTE_CONDITION_SUMMARY
}
return (
normalizeRiskRuleText(metadata.condition_summary) ||
normalizeRiskRuleText(params.condition_summary) ||
@@ -168,13 +245,220 @@ export function resolveRiskRuleFlow(payload, fields) {
const fieldSummary = buildRiskRuleFieldSummary(fields)
const conditionSummary = resolveRiskRuleConditionSummary(payload)
const severityLabel = resolveRiskRuleSeverityLabel(payload)
const isCityRouteRule = isCityRouteConsistencyPayload(payload)
return {
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
evidence: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
decision: normalizeRiskRuleText(flow.decision) || conditionSummary,
evidence: isCityRouteRule
? CITY_ROUTE_FLOW_EVIDENCE
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
decision: isCityRouteRule
? CITY_ROUTE_FLOW_DECISION
: normalizeRiskRuleText(flow.decision) || conditionSummary,
basis: conditionSummary,
...resolveRiskRuleFlowDetails(payload, fields),
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
}
}
function resolveRiskRuleFlowDetails(payload, fields) {
const params = payload && typeof payload === 'object' && payload.params && typeof payload.params === 'object'
? payload.params
: {}
const ruleIr = params.rule_ir && typeof params.rule_ir === 'object' ? params.rule_ir : {}
const facts = Array.isArray(ruleIr.facts) ? buildFactLines(ruleIr.facts, fields) : buildFieldFactLines(fields)
const conditions = buildConditionLines(params, fields)
const hitLogic = formatHitLogic(params.hit_logic) || normalizeRiskRuleText(params.formula)
return {
facts,
conditions,
hitLogic
}
}
function buildFactLines(facts, fields) {
const labelByKey = buildLabelByKey(fields)
const rows = facts
.slice(0, 4)
.map((fact) => {
const id = normalizeRiskRuleText(fact?.id)
const label = normalizeRiskRuleText(fact?.label || id || '事实')
const fieldKeys = readStringList(fact?.fields)
const fieldText = fieldKeys.slice(0, 3).map((key) => labelByKey[key] || key).join('')
return `${id ? `${id}=` : ''}${label}: ${fieldText || '规则字段'}`
})
.filter(Boolean)
return rows.length ? rows : buildFieldFactLines(fields)
}
function buildFieldFactLines(fields) {
return (Array.isArray(fields) ? fields : [])
.slice(0, 4)
.map((field, index) => `${String.fromCharCode(65 + index)}=${formatRiskRuleFieldDisplay(field)}`)
.filter(Boolean)
}
function buildConditionLines(params, fields) {
const labelByKey = buildLabelByKey(fields)
const conditions = Array.isArray(params.conditions) ? params.conditions : []
const rows = conditions
.slice(0, 4)
.map((condition, index) => formatConditionLine(condition, labelByKey, index + 1))
.filter(Boolean)
if (rows.length) {
return rows
}
return normalizeRiskRuleText(params.condition_summary) ? [normalizeRiskRuleText(params.condition_summary)] : []
}
function formatConditionLine(condition, labelByKey, index) {
const operator = normalizeRiskRuleText(condition?.operator)
const id = normalizeRiskRuleText(condition?.id || `C${index}`)
const prefix = `${id}: `
if (['not_in_scope', 'not_in_set', 'not_overlap'].includes(operator)) {
return `${prefix}${formatFieldGroup(condition?.left_fields, labelByKey)}${formatFieldGroup(condition?.right_fields, labelByKey)} = ∅`
}
if (['in_scope', 'overlap'].includes(operator)) {
return `${prefix}${formatFieldGroup(condition?.left_fields, labelByKey)}${formatFieldGroup(condition?.right_fields, labelByKey)} ≠ ∅`
}
if (operator === 'date_outside_range') {
return `${prefix}${formatFieldGroup(condition?.date_fields, labelByKey)} 不在 [${formatFieldGroup(condition?.range_start_fields, labelByKey)}, ${formatFieldGroup(condition?.range_end_fields, labelByKey)}]`
}
if (['contains_any', 'not_contains_any'].includes(operator)) {
const verb = operator === 'not_contains_any' ? '不含' : '包含'
const keywords = readStringList(condition?.keywords).slice(0, 4).join('、') || '关键词'
return `${prefix}${formatFieldGroup(condition?.fields, labelByKey)} ${verb} ${keywords}`
}
if (['exists_any', 'exists_all', 'all_present'].includes(operator)) {
const verb = operator === 'exists_any' ? '任一有值' : '全部有值'
return `${prefix}${formatFieldGroup(condition?.fields, labelByKey)} ${verb}`
}
const left = normalizeRiskRuleText(condition?.left)
const right = normalizeRiskRuleText(condition?.right)
if (left || right) {
return `${prefix}${labelByKey[left] || left} ${operator || 'compare'} ${labelByKey[right] || right}`
}
return `${prefix}${operator || '规则条件'}`
}
function formatFieldGroup(value, labelByKey) {
const keys = readStringList(value)
if (!keys.length) {
return '字段集合'
}
return keys.slice(0, 3).map((key) => labelByKey[key] || key).join('')
}
function formatHitLogic(value) {
if (typeof value === 'string') {
return normalizeRiskRuleText(value)
}
if (Array.isArray(value)) {
return value.map(formatHitLogic).filter(Boolean).join(' AND ')
}
if (!value || typeof value !== 'object') {
return ''
}
if (Array.isArray(value.all)) {
return value.all.map(wrapLogicPart).filter(Boolean).join(' AND ')
}
if (Array.isArray(value.any)) {
return value.any.map(wrapLogicPart).filter(Boolean).join(' OR ')
}
if (Object.prototype.hasOwnProperty.call(value, 'not')) {
const text = wrapLogicPart(value.not)
return text ? `NOT ${text}` : ''
}
return ''
}
function wrapLogicPart(value) {
const text = formatHitLogic(value)
return value && typeof value === 'object' && !Array.isArray(value) && text ? `(${text})` : text
}
function buildLabelByKey(fields) {
const map = {}
;(Array.isArray(fields) ? fields : []).forEach((field) => {
const key = normalizeRiskRuleText(field?.key)
if (key) {
map[key] = normalizeRiskRuleText(field?.label || key)
}
})
return map
}
function readStringList(value) {
return Array.isArray(value) ? value.map(normalizeRiskRuleText).filter(Boolean) : []
}
function isCityRouteConsistencyPayload(payload) {
if (!payload || typeof payload !== 'object') {
return false
}
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
const params = payload.params && typeof payload.params === 'object' ? payload.params : {}
const semanticType = normalizeRiskRuleText(payload.semantic_type || params.semantic_type)
if (CITY_ROUTE_SEMANTIC_TYPES.has(semanticType)) {
return true
}
const text = [
metadata.natural_language,
params.natural_language,
payload.description,
metadata.condition_summary,
params.condition_summary
]
.map(normalizeRiskRuleText)
.join('\n')
if (looksLikeCityRouteRuleText(text)) {
return true
}
const fieldKeys = new Set(resolveRiskRuleFieldKeys(payload))
const hasAttachmentCity =
fieldKeys.has('attachment.route_cities') || fieldKeys.has('attachment.hotel_city')
const hasReferenceCity = fieldKeys.has('claim.location') || fieldKeys.has('item.item_location')
return hasAttachmentCity && hasReferenceCity && text.includes('风险关键词')
}
function resolveRiskRuleFieldKeys(payload) {
const keys = []
const inputs = payload.inputs && typeof payload.inputs === 'object' ? payload.inputs : {}
if (Array.isArray(inputs.fields)) {
inputs.fields.forEach((item) => {
const key = normalizeRiskRuleText(item?.key)
if (key) keys.push(key)
})
}
;[payload.field_keys, payload.params?.field_keys, payload.params?.search_fields].forEach((rows) => {
if (!Array.isArray(rows)) return
rows.forEach((item) => {
const key = normalizeRiskRuleText(item)
if (key) keys.push(key)
})
})
return [...new Set(keys)]
}
function looksLikeCityRouteRuleText(text) {
const normalized = normalizeRiskRuleText(text)
if (!normalized) {
return false
}
const hasCitySubject = ['交通票', '住宿票', '住宿发票', '票据', '附件', '行程城市', '住宿城市'].some(
(term) => normalized.includes(term)
)
const hasReference = ['申报目的地', '申报地点', '明细地点', '发生地点', '意图城市', '目的地'].some(
(term) => normalized.includes(term)
)
const hasRelation = ['一致', '不一致', '形成一致关系', '匹配', '无法与', '对应'].some((term) =>
normalized.includes(term)
)
const hasRouteAnomaly = ['绕行', '跨城', '中转', '周转', '改签'].some((term) =>
normalized.includes(term)
)
return hasCitySubject && hasReference && (hasRelation || hasRouteAnomaly)
}

View File

@@ -4,6 +4,7 @@ import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './t
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
import {
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_START_REIMBURSEMENT,
GUIDED_ACTION_START_STATUS_QUERY
} from './travelReimbursementGuidedFlowModel.js'
@@ -163,7 +164,7 @@ export const EXPENSE_WELCOME_QUICK_ACTIONS = [
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
{
label: '快速发起申请',
prompt: '我想快速发起一笔费用申请,请先帮我判断申请类型并引导补充信息。',
action: GUIDED_ACTION_START_APPLICATION,
icon: 'mdi mdi-file-plus-outline'
},
{
@@ -252,6 +253,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
reviewPanelScope: '',
riskFlags: [],
pendingAttachmentAssociation: null,
applicationPreview: null,
...extras
}
}
@@ -801,6 +803,7 @@ export function serializeSessionMessages(messages) {
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []

View File

@@ -4,7 +4,7 @@ import {
} from './travelReimbursementReviewModel.js'
export const EXPENSE_QUERY_PAGE_SIZE = 5
export const EXPENSE_CENTER_HREF = '/app/requests'
export const EXPENSE_CENTER_HREF = '/app/documents'
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
const EXPENSE_STATUS_LABELS = {
draft: '草稿',
@@ -282,7 +282,7 @@ export function buildExpenseQueryHint(queryPayload) {
if (queryPayload.selectionLocked && queryPayload.selectedClaimId) {
return '已选择关联草稿,附件将按该单据继续识别和归集。'
}
return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。'
return '如果这些都不是本次要关联的单据,可以补充单号或先到单据中心新建草稿。'
}
const parts = []
@@ -290,9 +290,9 @@ export function buildExpenseQueryHint(queryPayload) {
const totalCount = Math.max(0, Number(queryPayload.recordCount || 0))
if (totalCount > previewLimit) {
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到报销中心查看。`)
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到单据中心查看。`)
} else if (totalCount > 0) {
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入报销中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入单据中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
}
return parts.join('。')

View File

@@ -3,6 +3,7 @@ export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
export const GUIDED_ACTION_START_REIMBURSEMENT = 'start_guided_reimbursement'
export const GUIDED_ACTION_START_APPLICATION = 'start_guided_application'
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
@@ -445,7 +446,7 @@ export function resolveGuidedQueryModeFromText(text) {
if (!normalized) return ''
const exact = GUIDED_QUERY_MODES.find((item) => normalized === item.label || normalized === item.key)
if (exact) return exact.key
if (/单号|编号|EXP-/i.test(normalized)) return 'claim_no'
if (/单号|编号|EXP-|APP-|AP-|RE-|AD-/i.test(normalized)) return 'claim_no'
if (/状态|草稿|审批|退回|归档|完成/.test(normalized)) return 'status'
if (/上周|本周|去年|今年|月份|时间|日期|[0-9]{4}-[0-9]{2}/.test(normalized)) return 'time_range'
return 'keyword'
@@ -484,7 +485,7 @@ export function buildGuidedQueryPromptText(state) {
].join('\n')
}
const prompts = {
claim_no: '请输入报销单号,例如 EXP-202605-001。',
claim_no: '请输入单据编号,例如 RE-20260525103045-ABCDEFGH。',
time_range: '请输入查询时间范围,例如:上周、今年 5 月、2025 年全年。',
keyword: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
}

View File

@@ -69,6 +69,7 @@ export function isApplicationDocumentRequest(request) {
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')

View File

@@ -0,0 +1,105 @@
import { ref } from 'vue'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
buildApplicationPreviewRows,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
const applicationPreviewEditor = ref({
messageId: '',
fieldKey: '',
draftValue: ''
})
function resolveApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
function resolveApplicationPreviewEditorControl(fieldKey) {
return fieldKey === 'transportMode' ? 'select' : 'text'
}
function resolveApplicationPreviewEditorOptions(fieldKey) {
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
}
function isApplicationPreviewEditing(message, fieldKey) {
return (
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
applicationPreviewEditor.value.fieldKey === fieldKey
)
}
function openApplicationPreviewEditor(message, fieldKey, value) {
if (!message?.applicationPreview || !fieldKey) return
const targetRow = buildApplicationPreviewRows(message.applicationPreview)
.find((row) => row.key === fieldKey)
if (targetRow && targetRow.editable === false) return
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
applicationPreviewEditor.value = {
messageId: String(message.id || ''),
fieldKey,
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue
}
}
function cancelApplicationPreviewEditor() {
applicationPreviewEditor.value = {
messageId: '',
fieldKey: '',
draftValue: ''
}
}
function commitApplicationPreviewEditor(message) {
const editor = applicationPreviewEditor.value
if (!message?.applicationPreview || String(editor.messageId || '') !== String(message.id || '') || !editor.fieldKey) {
cancelApplicationPreviewEditor()
return false
}
const nextValue = String(editor.draftValue || '').trim()
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: {
...(message.applicationPreview.fields || {}),
[editor.fieldKey]: nextValue
}
})
message.applicationPreview = nextPreview
message.text = buildLocalApplicationPreviewMessage(nextPreview)
cancelApplicationPreviewEditor()
persistSessionState?.()
toast?.('已更新核对表内容。')
return true
}
function handleApplicationPreviewEditorKeydown(event, message) {
if (event.key === 'Enter') {
event.preventDefault()
commitApplicationPreviewEditor(message)
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelApplicationPreviewEditor()
}
}
return {
applicationPreviewEditor,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
cancelApplicationPreviewEditor,
handleApplicationPreviewEditorKeydown
}
}

View File

@@ -1,6 +1,11 @@
import { ref } from 'vue'
import {
buildApplicationTemplatePreview,
buildLocalApplicationPreviewMessage
} from '../../utils/expenseApplicationPreview.js'
import {
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
@@ -84,6 +89,7 @@ export function useTravelReimbursementGuidedFlow({
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer,
currentUser,
toast
}) {
const guidedPendingFiles = ref([])
@@ -134,6 +140,16 @@ export function useTravelReimbursementGuidedFlow({
persistAndScroll()
}
function startGuidedApplicationTemplate() {
resetGuidedFlowState()
const applicationPreview = buildApplicationTemplatePreview(currentUser?.value || {})
pushAssistant(buildLocalApplicationPreviewMessage(applicationPreview), {
meta: ['申请模板'],
applicationPreview
})
persistAndScroll()
}
function startGuidedStatusQuery() {
guidedFlowState.value = createGuidedStatusQueryState()
guidedPendingFiles.value = []
@@ -146,6 +162,10 @@ export function useTravelReimbursementGuidedFlow({
function handleGuidedShortcut(shortcut) {
const actionType = normalizeText(shortcut?.action)
if (actionType === GUIDED_ACTION_START_APPLICATION) {
startGuidedApplicationTemplate()
return true
}
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
startGuidedReimbursement()
return true

View File

@@ -4,6 +4,18 @@ import {
buildUnsavedDraftAttachmentConfirmationMessage
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import {
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
@@ -46,6 +58,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
fetchExpenseClaims,
fileInputRef,
flowRunId,
insightPanelCollapsed,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
@@ -281,6 +294,73 @@ export function useTravelReimbursementSubmitComposer(ctx) {
).trim()
}
function buildApplicationPreviewReviewMeta(ontology) {
return [
'申请核对预览',
String(ontology?.parse_strategy || '').trim() === 'llm_primary'
? '模型复核完成'
: '规则兜底复核'
]
}
async function buildApplicationPreviewWithModelReview(rawText) {
const user = currentUser.value || {}
const localPreview = buildLocalApplicationPreview(rawText, user)
const enrichWithPolicyEstimate = async (preview) => {
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
if (!estimateRequest.canCalculate) {
return preview
}
try {
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
console.warn('Application policy estimate failed:', error)
return applyApplicationPolicyEstimateError(preview, error, user)
}
}
try {
const ontology = await fetchOntologyParse(
{
query: rawText,
user_id: user.username || user.name || 'anonymous',
context_json: {
...buildExpenseApplicationOntologyContext(user),
session_type: activeSessionType.value,
entry_source: props.entrySource,
user_input_text: rawText
}
},
{
timeoutMs: 45000,
timeoutMessage: '模型抽取申请字段超时,已保留当前本地预览。'
}
)
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
ontology,
rawText,
user
)
return {
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
meta: buildApplicationPreviewReviewMeta(ontology)
}
} catch (error) {
console.warn('Application preview model refinement failed:', error)
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'failed'
}),
meta: ['申请核对预览', '模型复核失败']
}
}
}
async function submitComposer(options = {}) {
if (submitting.value || sessionSwitchBusy.value) return null
@@ -388,6 +468,84 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return null
}
if (shouldUseLocalApplicationPreview(rawText, {
sessionType: activeSessionType.value,
attachmentCount: files.length,
reviewAction,
systemGenerated
})) {
const intentStartedAt = Date.now()
const reviewStartedAt = intentStartedAt
resetFlowRun()
startFlowStep('intent', {
title: '业务意图识别',
tool: 'ontology.intent_detection',
detail: '正在识别是否为费用申请事项...'
})
startFlowStep('application-review-preview', {
title: '申请信息核对',
tool: 'ontology.application_review',
detail: '正在进行申请信息模型复核...'
})
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingMessage = createMessage(
'assistant',
'正在进行申请信息模型复核。本步骤只识别意图和抽取字段,不会创建、更新或保存草稿。',
[],
{
meta: ['模型复核中']
}
)
messages.value.push(pendingMessage)
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
submitting.value = true
try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText)
const reviewStatus = String(meta?.[1] || '').trim()
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
reviewStatus === '模型复核完成'
? '模型复核完成,已生成申请核对表'
: reviewStatus === '模型复核失败'
? '模型复核失败,已生成临时核对表'
: '模型未返回稳定结果,已完成规则兜底核对',
Date.now() - reviewStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildLocalApplicationPreviewMessage(applicationPreview),
[],
{
meta,
applicationPreview
}
))
if (insightPanelCollapsed) {
insightPanelCollapsed.value = true
}
persistSessionState()
nextTick(scrollToBottom)
} finally {
submitting.value = false
}
return null
}
const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value &&
files.length &&

View File

@@ -3,6 +3,8 @@ import test from 'node:test'
import {
canApproveLeaderExpenseClaims,
canAccessAppView,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims
} from '../src/utils/accessControl.js'
@@ -28,6 +30,21 @@ test('finance can return and final approve, but only executives can manage delet
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
})
test('archived claims can only be deleted by admin users', () => {
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['executive'] }), false)
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['finance'] }), false)
assert.equal(canDeleteArchivedExpenseClaims({ isAdmin: true, roleCodes: ['manager'] }), true)
})
test('legacy reimbursement approval and archive centers are no longer accessible app views', () => {
const adminUser = { isAdmin: true, roleCodes: ['manager', 'finance'] }
assert.equal(canAccessAppView(adminUser, 'requests'), false)
assert.equal(canAccessAppView(adminUser, 'approval'), false)
assert.equal(canAccessAppView(adminUser, 'archive'), false)
assert.equal(canAccessAppView(adminUser, 'documents'), true)
})
test('finance approval inbox only processes finance-stage requests', () => {
const financeUser = { roleCodes: ['finance'], name: '财务' }

View File

@@ -101,8 +101,8 @@ test('detail topbar still flags real manual rows without required ticket info',
test('application detail topbar does not ask for receipt attachments', () => {
const request = {
id: 'APP-20260525-ABC123',
claimNo: 'APP-20260525-ABC123',
id: 'AP-20260525103045-ABCDEFGH',
claimNo: 'AP-20260525103045-ABCDEFGH',
documentTypeCode: 'application',
node: '直属领导审批',
approvalKey: 'in_progress',

View File

@@ -53,6 +53,16 @@ test('financial assistant toolbar renders four isolated assistant sessions', ()
assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/)
})
test('closing a busy assistant keeps the running instance recoverable', () => {
assert.match(appShellRouteView, /:reopen-token="smartEntryRevealToken"/)
assert.match(appShellComposable, /const smartEntryRevealToken = ref\(0\)/)
assert.match(appShellComposable, /if \(smartEntryOpen\.value\) \{\s*smartEntryRevealToken\.value \+= 1\s*return\s*\}/)
assert.match(appShellComposable, /smartEntryRevealToken,/)
assert.match(assistantScript, /reopenToken:\s*\{\s*type:\s*Number/)
assert.match(assistantScript, /closeAfterBusy\.value = false[\s\S]*workbenchVisible\.value = true/)
assert.match(assistantScript, /function emitCloseAfterLeave\(\) \{\s*if \(workbenchVisible\.value\)/)
})
test('financial assistant welcome copy differentiates application intent from reimbursement entry', () => {
const user = { name: '李文静', username: 'wenjing.li', grade: 'P5' }
const applicationWelcome = buildWelcomeMessage('application', null, SESSION_TYPE_APPLICATION, user)

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildLeaderApprovalInfo,
resolveGeneratedDraftClaimNo
} from '../src/utils/applicationApproval.js'
test('buildLeaderApprovalInfo extracts leader opinion and generated reimbursement draft', () => {
const info = buildLeaderApprovalInfo({
profileManager: '王经理',
riskFlags: [
{
source: 'manual_return',
opinion: '需要补充预算口径',
created_at: '2026-05-24T09:00:00'
},
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'li.manager@example.com',
operator_name: '李经理',
opinion: '业务必要,同意申请。',
previous_approval_stage: '直属领导审批',
next_approval_stage: '审批完成',
generated_draft_claim_no: 'EXP-202605-0007',
created_at: '2026-05-25T10:15:00'
}
]
})
assert.deepEqual(info, {
opinion: '业务必要,同意申请。',
operator: '李经理',
time: '2026-05-25 10:15',
generatedDraftClaimNo: 'EXP-202605-0007'
})
})
test('resolveGeneratedDraftClaimNo reads approval response payload', () => {
assert.equal(
resolveGeneratedDraftClaimNo({
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
generated_draft_claim_no: 'EXP-202605-0012',
created_at: '2026-05-25T11:00:00'
}
]
}),
'EXP-202605-0012'
)
})

View File

@@ -90,12 +90,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
assert.ok(handleDraftSavedBlock)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: activeView\.value === 'documents' \? 'app-documents' : 'app-requests' \}\)/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })", draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1)
})
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {

View File

@@ -25,7 +25,7 @@ test('suggested action prefill falls back to application field templates', () =>
action_type: 'prefill_composer',
payload: { application_field: 'amount' }
}),
'预计总费用:'
'用户预估费用:'
)
assert.equal(
resolveSuggestedActionPrefill({

View File

@@ -257,7 +257,7 @@ test('expense query info items render as prompts instead of low risk', () => {
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
})
test('expense query hint guides users to the reimbursement center after the top five results', () => {
test('expense query hint guides users to the document center after the top five results', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',
title: '最近 5 条你的归档报销单',

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
excludeArchivedDocumentRows,
isArchivedDocumentRow
} from '../src/utils/documentCenterRows.js'
test('document center archived rows are detected from archive flag or request stage', () => {
assert.equal(isArchivedDocumentRow({ archived: true }), true)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'approved', approval_stage: '归档入账' }
}),
true
)
assert.equal(
isArchivedDocumentRow({
rawRequest: {
status: 'approved',
approval_stage: '审批完成',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application'
}
}),
true
)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'in_progress', approval_stage: '部门审批' }
}),
false
)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'approved', approval_stage: '部门审批', approvalKey: 'completed' }
}),
false
)
})
test('document center all scope excludes archived rows from merged lists', () => {
const rows = excludeArchivedDocumentRows([
{ claimId: 'a', archived: true },
{ claimId: 'b', rawRequest: { status: 'approved', approval_stage: '归档入账' } },
{ claimId: 'c', rawRequest: { status: 'submitted', approval_stage: '部门审批' } }
])
assert.deepEqual(rows.map((row) => row.claimId), ['c'])
})

View File

@@ -10,6 +10,7 @@ import {
resolveDocumentNewKey,
writeDocumentScope
} from '../src/utils/documentCenterNewState.js'
import { buildDocumentInboxRows } from '../src/composables/useDocumentCenterInbox.js'
function createMemoryStorage(initial = {}) {
const store = new Map(Object.entries(initial))
@@ -46,6 +47,16 @@ test('document center new state counts unseen documents and persists viewed rows
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
})
test('document center sidebar inbox shares source scoped document keys', () => {
const rows = buildDocumentInboxRows({
ownedClaims: [{ id: 'claim-1', claim_no: 'EXP-1' }],
approvalClaims: [{ id: 'claim-1', claim_no: 'EXP-1' }],
archivedClaims: [{ id: 'claim-2', claim_no: 'EXP-2' }]
})
assert.deepEqual(rows.map((row) => resolveDocumentNewKey(row)), ['approval:claim-1', 'archive:claim-2'])
})
test('document center scope state restores only allowed tabs', () => {
const storage = createMemoryStorage()
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']

View File

@@ -39,7 +39,10 @@ test('documents center top tabs start from all and show document category labels
})
test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(documentsCenterView, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/

View File

@@ -0,0 +1,291 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewRows,
buildApplicationPreviewSubmitText,
buildApplicationTemplatePreview,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const conversationModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
)
const previewEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
'utf8'
)
test('application intent uses local preview instead of immediate orchestrator call', () => {
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目出差3天高铁预计金额2358元'
assert.equal(
shouldUseLocalApplicationPreview(prompt, {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
true
)
assert.equal(
shouldUseLocalApplicationPreview('帮我查询申请状态', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', grade: 'P5' })
assert.equal(preview.fields.applicationType, '差旅费用申请')
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.fields.amount, '2358元')
assert.equal(preview.fields.grade, 'P5')
assert.equal(preview.readyToSubmit, true)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静',
grade: 'P5'
})
assert.equal(preview.fields.location, '新疆,伊犁')
assert.equal(preview.fields.reason, '服务美团业务部署')
const editedPreview = normalizeApplicationPreview({
...preview,
fields: {
...preview.fields,
reason: '客户现场项目支持',
amount: '1900元'
}
})
const rows = buildApplicationPreviewRows(editedPreview)
assert.deepEqual(
rows.map((row) => row.label),
['申请类型', '职级', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '用户预估费用']
)
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用1900元/)
})
test('application preview cleans empty time labels and keeps only business reason', () => {
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
grade: 'P5'
})
assert.equal(preview.fields.location, '九江')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.reason, '服务美团业务部署')
assert.equal(preview.fields.transportMode, '火车')
assert.doesNotMatch(preview.fields.reason, /发生时间|去九江|出差3天/)
})
test('application preview can be refined by ontology model extraction', () => {
const rawText = '发生时间去九江出差3天服务美团业务部署预计费用1800元火车'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '九江', normalized_value: '九江' },
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' },
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
{ type: 'amount', value: '1800元', normalized_value: '1800' }
],
time_range: {},
missing_slots: []
},
rawText,
{ name: '李文静', grade: 'P5' }
)
assert.equal(refinedPreview.modelRefined, true)
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
assert.equal(refinedPreview.modelReviewStatus, 'completed')
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
assert.equal(refinedPreview.fields.time, '')
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview keeps rule fallback distinct from model reviewed result', () => {
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署出差3天火车预计费用1800元'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const fallbackPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'rule_fallback',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海', normalized_value: '上海' },
{ type: 'amount', value: '1800元', normalized_value: '1800' }
],
time_range: {
start: '2026-05-20',
end: '2026-05-23'
},
missing_slots: []
},
rawText,
{ name: '李文静', grade: 'P5' }
)
const message = buildLocalApplicationPreviewMessage(fallbackPreview)
const footer = buildApplicationPreviewFooterMessage(fallbackPreview)
assert.equal(fallbackPreview.modelReviewStatus, 'fallback')
assert.match(message, /规则兜底/)
assert.match(footer, /规则兜底/)
assert.doesNotMatch(footer, /#application-submit/)
})
test('application preview with missing budget stays in chat and asks for补充信息', () => {
const preview = buildLocalApplicationPreview('我想申请去北京出差,高铁,但是不知道预算', {
name: '李文静',
grade: 'P5'
})
assert.equal(preview.fields.amount, '待测算')
assert.equal(preview.readyToSubmit, false)
assert.match(buildLocalApplicationPreviewMessage(preview), /下方表格/)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /当前还需要补充/)
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
})
test('application quick start renders a template without model review', () => {
const preview = buildApplicationTemplatePreview({
name: '李文静',
departmentName: '财务部',
grade: 'P5'
})
const message = buildLocalApplicationPreviewMessage(preview)
assert.equal(preview.modelReviewStatus, 'template')
assert.equal(preview.fields.applicationType, '费用申请')
assert.equal(preview.fields.applicant, '李文静')
assert.equal(preview.fields.department, '财务部')
assert.equal(preview.fields.grade, 'P5')
assert.match(message, /不调用大模型/)
assert.match(message, /点击对应行直接填写/)
assert.doesNotMatch(message, /#application-submit/)
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
})
test('application session shows intent flow, persists preview, and supports inline table edit', () => {
assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/)
assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/)
assert.match(submitComposerScript, /buildApplicationPreviewWithModelReview/)
assert.match(submitComposerScript, /fetchOntologyParse/)
assert.match(submitComposerScript, /calculateTravelReimbursement/)
assert.match(submitComposerScript, /buildApplicationPolicyEstimateRequest/)
assert.match(submitComposerScript, /模型复核中/)
assert.match(submitComposerScript, /startFlowStep\('intent'/)
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
assert.match(submitComposerScript, /insightPanelCollapsed\.value = true/)
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
assert.ok(
submitComposerScript.indexOf('shouldUseLocalApplicationPreview') <
submitComposerScript.indexOf('const payload = await runOrchestrator')
)
assert.match(createViewScript, /const isApplicationSession = computed/)
assert.match(createViewScript, /insightPanelCollapsed,/)
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
assert.match(createViewScript, /flowSteps\.value\.length > 0/)
assert.match(createViewScript, /useApplicationPreviewEditor/)
assert.match(createViewScript, /message-bubble-application-preview/)
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
assert.match(conversationModelScript, /applicationPreview: null/)
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
assert.match(createViewTemplate, /class="application-preview-table"/)
assert.match(createViewTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
assert.match(createViewTemplate, /v-html="renderMarkdown\(buildApplicationPreviewFooterText\(message\)\)"/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
assert.match(createViewTemplate, /v-model="applicationPreviewEditor\.draftValue"/)
assert.match(createViewTemplate, /application-preview-select/)
assert.match(createViewTemplate, /resolveApplicationPreviewEditorOptions/)
assert.match(createViewTemplate, /row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
assert.match(createViewTemplate, /@keydown\.enter\.prevent="row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
assert.match(createViewTemplate, /@keydown\.stop="handleApplicationPreviewEditorKeydown\(\$event, message\)"/)
assert.match(createViewTemplate, /mdi mdi-pencil-outline/)
assert.match(createViewTemplate, /@click\.stop="openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
assert.match(createViewTemplate, /openApplicationPreviewEditor/)
assert.match(createViewTemplate, /commitApplicationPreviewEditor/)
assert.match(previewEditorScript, /normalizeApplicationPreview/)
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
assert.match(previewEditorScript, /targetRow\.editable === false/)
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天服务项目部署火车预计费用1800元', {
name: '李文静',
grade: 'P5'
})
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 600,
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
total_amount: 2160,
rule_name: '公司差旅费报销规则',
rule_version: '2026版'
}, { grade: 'P5' })
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
assert.match(estimatedPreview.fields.transportPolicy, /实报实销/)
assert.match(estimatedPreview.fields.policyEstimate, /2,160元/)
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})

View File

@@ -43,6 +43,22 @@ test('expense application prompt field parser supports multiline labels', () =>
assert.equal(resolveApplicationReason(structuredApplicationPrompt), '支撑国网服务器部署')
})
test('expense application reason prefers model entity and strips context fragments', () => {
assert.equal(
resolveApplicationReason(
'发生时间去九江出差3天服务美团业务部署',
{
entities: [
{ type: 'location', value: '九江', normalized_value: '九江' },
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' }
]
}
),
'服务美团业务部署'
)
assert.equal(resolveApplicationReason('发生时间去九江出差3天服务美团业务部署'), '服务美团业务部署')
})
test('expense application expands a single selected date with natural days', () => {
const prompt = [
'发生时间2026-05-25',

View File

@@ -33,7 +33,7 @@ test('expense application submit uses rich text link and confirm dialog', () =>
)
assert.match(
createViewScript,
/async function confirmApplicationSubmit\(\)[\s\S]*rawText: '确认提交'[\s\S]*systemGenerated: true/
/async function confirmApplicationSubmit\(\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
)
assert.match(
createViewScript,

View File

@@ -10,6 +10,15 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => {
isArchivedExpenseClaim({ status: 'approved', approval_stage: '归档入账' }),
true
)
assert.equal(
isArchivedExpenseClaim({
status: 'approved',
approval_stage: '审批完成',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application'
}),
true
)
})
test('isArchivedExpenseClaim ignores in-progress claims', () => {
@@ -19,7 +28,7 @@ test('isArchivedExpenseClaim ignores in-progress claims', () => {
)
})
test('archive center is wired into navigation and api client', () => {
test('archive data stays available through api client but archive center is removed from navigation', () => {
const navigationScript = readFileSync(
fileURLToPath(new URL('../src/composables/useNavigation.js', import.meta.url)),
'utf8'
@@ -29,7 +38,7 @@ test('archive center is wired into navigation and api client', () => {
'utf8'
)
assert.match(navigationScript, /id:\s*'archive'/)
assert.doesNotMatch(navigationScript, /id:\s*'archive'/)
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
})
@@ -43,10 +52,13 @@ test('archive center uses generic archive category and type wording', () => {
'utf8'
)
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT\]/)
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_APPLICATION, ARCHIVE_TAB_REIMBURSEMENT\]/)
assert.match(archiveScript, /const ARCHIVE_TAB_APPLICATION = '申请归档'/)
assert.match(archiveScript, /const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'/)
assert.match(archiveScript, /archiveType:\s*ARCHIVE_TYPE_REIMBURSEMENT/)
assert.match(archiveScript, /archiveTypeCode:\s*ARCHIVE_TYPE_REIMBURSEMENT_CODE/)
assert.match(archiveScript, /archiveType:\s*isApplicationDocument \? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT/)
assert.match(archiveScript, /archiveTab:\s*isApplicationDocument \? ARCHIVE_TAB_APPLICATION : ARCHIVE_TAB_REIMBURSEMENT/)
assert.match(archiveScript, /const ARCHIVE_TYPE_REIMBURSEMENT = '报销'/)
assert.match(archiveScript, /const ARCHIVE_TYPE_APPLICATION = '申请'/)
assert.doesNotMatch(archiveScript, /'差旅报销'/)
assert.doesNotMatch(archiveScript, /'招待报销'/)
assert.doesNotMatch(archiveScript, /'其他费用'/)

View File

@@ -1,13 +1,15 @@
import assert from 'node:assert/strict'
import {
appViews,
navItems,
resolveAppViewFromRoute,
resolveTargetRouteName
} from '../src/composables/useNavigation.js'
function testDerivesViewFromRouteName() {
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'requests')
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'documents')
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
}
@@ -19,13 +21,24 @@ function testFallsBackToValidMeta() {
function testResolvesMainRouteNames() {
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
assert.equal(resolveTargetRouteName('archive'), 'app-overview')
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
}
function testLegacyCentersAreRemovedFromNavigation() {
assert.equal(appViews.includes('requests'), false)
assert.equal(appViews.includes('approval'), false)
assert.equal(appViews.includes('archive'), false)
assert.equal(navItems.some((item) => ['requests', 'approval', 'archive'].includes(item.id)), false)
}
function run() {
testDerivesViewFromRouteName()
testFallsBackToValidMeta()
testResolvesMainRouteNames()
testLegacyCentersAreRemovedFromNavigation()
console.log('navigation route resolution tests passed')
}

View File

@@ -6,7 +6,7 @@ import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
test('application claims are mapped as application documents', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-1',
claim_no: 'APP-20260525-ABC123',
claim_no: 'AP-20260525103045-ABCDEFGH',
employee_name: '张三',
department_name: '交付部',
expense_type: 'travel_application',
@@ -42,7 +42,7 @@ test('application claims are mapped as application documents', () => {
test('approved application claims complete after direct manager approval only', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-approved',
claim_no: 'APP-20260525-DONE01',
claim_no: 'AP-20260525113045-HGFEDCBA',
employee_name: '张三',
department_name: '交付部',
manager_name: '李经理',

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeRequestForUi } from '../src/utils/requestViewModel.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../src/utils/requestViewModel.js'
test('normalizes backend approval_stage for in-progress claim details', () => {
const request = normalizeRequestForUi({
@@ -45,3 +45,42 @@ test('does not show manager email as direct supervisor name', () => {
assert.equal(request.profileManager, '待补充')
})
test('detects archived claim view models for delete permission gating', () => {
assert.equal(
isArchivedRequestView({
status: 'approved',
approval_stage: '归档入账',
approvalKey: 'completed'
}),
true
)
assert.equal(
isArchivedRequestView({
status: 'submitted',
approval_stage: '财务审批',
approvalKey: 'in_progress'
}),
false
)
assert.equal(
isArchivedRequestView({
status: 'approved',
approval_stage: '审批完成',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application',
approvalKey: 'completed'
}),
true
)
assert.equal(
isArchivedRequestView({
status: 'approved',
approval_stage: '审批完成',
claim_no: 'RE-20260525120000-HGFEDCBA',
expense_type: 'travel',
approvalKey: 'completed'
}),
false
)
})

View File

@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const sidebar = readFileSync(
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
'utf8'
)
const documentInbox = readFileSync(
fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)),
'utf8'
)
const documentNewState = readFileSync(
fileURLToPath(new URL('../src/utils/documentCenterNewState.js', import.meta.url)),
'utf8'
)
test('sidebar renders a red dot for unread document center rows', () => {
assert.match(sidebar, /useDocumentCenterInbox/)
assert.match(sidebar, /hasUnread: documentInboxHasUnread/)
assert.match(sidebar, /<span v-if="item\.hasNewMessage" class="nav-unread-dot" aria-hidden="true"><\/span>/)
assert.match(sidebar, /hasNewMessage: item\.id === 'documents' \? documentInboxHasUnread\.value : false/)
assert.match(sidebar, /void refreshDocumentInbox\(\)/)
assert.match(sidebar, /startDocumentInboxPolling\(\)/)
assert.match(sidebar, /stopDocumentInboxPolling\(\)/)
assert.match(sidebar, /\.nav-unread-dot\s*\{[\s\S]*background:\s*#ef4444;/)
assert.match(sidebar, /\.rail-collapsed \.nav-unread-dot\s*\{[\s\S]*position:\s*absolute;/)
})
test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /readViewedDocumentKeys/)
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentInbox, /fetchExpenseClaims/)
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
assert.match(documentInbox, /window\.addEventListener\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys\)/)
assert.match(documentNewState, /export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentNewState, /window\.dispatchEvent\(new CustomEvent\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT\)\)/)
})

View File

@@ -18,6 +18,7 @@ import {
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_QUERY_MODE,
@@ -90,6 +91,16 @@ test('assistant session modes expose independent quick actions', () => {
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
)
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].action, GUIDED_ACTION_START_APPLICATION)
assert.ok(!buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].prompt)
assert.ok(
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
)
assert.ok(
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
)
assert.match(guidedFlowScript, /GUIDED_ACTION_START_APPLICATION/)
assert.match(guidedFlowScript, /buildApplicationTemplatePreview/)
assert.deepEqual(
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)

View File

@@ -182,7 +182,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
]
}
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/requests/claim-1' })
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/documents/claim-1' })
const rendered = renderMarkdown(copy)
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
@@ -192,7 +192,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
assert.doesNotMatch(copy, /#review-risk-high/)
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(copy, /\[快速修改单据信息\]\(\/app\/requests\/claim-1\)/)
assert.match(copy, /\[快速修改单据信息\]\(\/app\/documents\/claim-1\)/)
assert.doesNotMatch(rendered, /markdown-risk-link-/)
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
@@ -209,7 +209,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
...reviewPayload,
risk_briefs: [{ level: 'high', title: '金额超标' }]
},
{ detailHref: '/app/requests/claim-1' }
{ detailHref: '/app/documents/claim-1' }
)
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
@@ -280,7 +280,7 @@ test('review risk drawer lists risk briefs without score and posts details into
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
@@ -314,7 +314,7 @@ test('submit composer scopes the side panel to intent overview, document upload,
assert.match(submitComposerScript, /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/)
})
test('expense query answers keep one clear result structure with reimbursement center jump link', () => {
test('expense query answers keep one clear result structure with document center jump link', () => {
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.meta\?\.length/)
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.suggestedActions\?\.length/)
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
@@ -351,12 +351,17 @@ test('closing the assistant while OCR is running defers unmount until the curren
})
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
assert.match(createViewTemplate, /v-if="canShowTravelCalculator" class="travel-calculator-anchor"/)
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
assert.match(createViewScript, /calculateTravelReimbursement/)
assert.match(createViewScript, /const canShowTravelCalculator = computed\(\(\) => activeSessionType\.value === SESSION_TYPE_EXPENSE\)/)
assert.match(createViewScript, /function openTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
assert.match(createViewScript, /function toggleTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /watch\(canShowTravelCalculator,[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
assert.match(createViewScript, /根据您输入的地点和天数/)
assert.match(createViewScript, /匹配到您要出差的地区为/)

View File

@@ -11,6 +11,10 @@ const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
@@ -49,26 +53,44 @@ test('approval-mode detail collects leader opinion and confirms approval before
assert.match(detailScript, /approvalOpinionTitle/)
assert.match(detailScript, /approvalConfirmDescription/)
assert.match(detailScript, /approvalNextStage/)
assert.match(detailScript, /showApplicationLeaderOpinionInput/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
assert.match(detailScript, /buildLeaderApprovalInfo/)
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /approveActionLabel/)
assert.match(detailScript, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /请先填写领导意见,填写后才能确认审核。/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /toast\(approvalSuccessToast\.value\)/)
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.match(detailTemplate, /v-if="showApplicationLeaderOpinion"/)
assert.match(detailTemplate, /class="application-leader-opinion"/)
assert.match(detailTemplate, /领导意见/)
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
assert.match(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /maxlength="500"\s+:required="requiresApprovalOpinion"/)
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/)
assert.match(detailTemplate, /\{\{ approveBusy \? approveBusyLabel : approveActionLabel \}\}/)
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
assert.match(detailTemplate, /:badge="approvalConfirmBadge"/)
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /confirm-text="确认通过"/)
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
assert.match(detailTemplate, /:description="returnDialogDescription"/)
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
assert.match(confirmApproveRequest, /approveExpenseClaim/)
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)
assert.match(detailStyles, /\.application-leader-opinion-head span \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
assert.match(reimbursementService, /\/approve/)
})

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
)
const responsiveStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view-part2.css', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
test('detail hero facts keep document number and date on one row on laptop screens', () => {
assert.match(detailStyles, /\.hero-fact strong \{[\s\S]*overflow-wrap:\s*anywhere/)
assert.match(detailStyles, /\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(240px,\s*1\.25fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(280px,\s*1\.4fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact strong \{[\s\S]*white-space:\s*nowrap/)
assert.match(detailStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
assert.match(detailStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
assert.doesNotMatch(detailScript, /key:\s*'status'[\s\S]*label:\s*'当前状态'/)
})

View File

@@ -425,8 +425,9 @@ test('expense detail table shows the amount total below detail rows', () => {
test('additional note is shown above expense details as travel purpose text', () => {
assert.ok(
detailViewTemplate.indexOf('<h3>附加说明</h3>')
< detailViewTemplate.indexOf("isApplicationDocument ? '申请预算' : '费用明细'")
< detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
)
assert.match(detailViewTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
@@ -547,8 +548,12 @@ test('expense detail save is blocked while attachment recognition is running', (
test('application detail uses application labels instead of reimbursement labels', () => {
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请预算' : '费用明细'/)
assert.match(detailViewTemplate, /无需补充任何报销票据/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
assert.match(detailViewTemplate, /class="application-detail-facts"/)
assert.match(detailViewTemplate, /applicationDetailFactItems/)
assert.match(detailViewScript, /buildApplicationDetailFactItems/)
assert.match(detailViewStyle, /\.application-detail-fact\.highlight strong/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)

View File

@@ -75,3 +75,11 @@ test('detail header and fallback progress use reimbursement wording', () => {
assert.match(detailExpenseModelScript, /label:\s*'创建单据'/)
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
})
test('archived detail delete action is gated by admin-only permission', () => {
assert.match(detailViewScript, /canDeleteArchivedExpenseClaims/)
assert.match(detailViewScript, /isArchivedRequestView/)
assert.match(detailViewScript, /if \(isArchivedRequest\.value\) {\s*return canDeleteArchivedExpenseClaims\(currentUser\.value\)/)
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canDeleteRequest"/)
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
})