feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
@@ -506,6 +506,29 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.rail-tooltip-popper) {
|
||||
max-width: 180px;
|
||||
padding: 7px 10px !important;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important;
|
||||
border-radius: 4px !important;
|
||||
background: rgba(255, 255, 255, 0.98) !important;
|
||||
color: #1f2937 !important;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.14) !important;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
:global(.rail-tooltip-popper.el-popper.is-light) {
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important;
|
||||
}
|
||||
|
||||
:global(.rail-tooltip-popper .el-popper__arrow::before) {
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important;
|
||||
background: rgba(255, 255, 255, 0.98) !important;
|
||||
}
|
||||
|
||||
@keyframes railUserMenuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -270,6 +270,12 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-alert-pill.neutral {
|
||||
border-color: #d7e0ea;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.detail-alert-pill.success {
|
||||
border-color: var(--success-line);
|
||||
background: var(--success-soft);
|
||||
|
||||
@@ -508,3 +508,17 @@
|
||||
max-height: min(34dvh, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
.review-insight-title-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.review-insight-title-copy .title-icon {
|
||||
font-size: 20px;
|
||||
color: var(--theme-primary, #3a7ca5);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
210
web/src/assets/styles/detail-page-corners.css
Normal file
210
web/src/assets/styles/detail-page-corners.css
Normal file
@@ -0,0 +1,210 @@
|
||||
:root {
|
||||
--enterprise-detail-radius: 4px;
|
||||
}
|
||||
|
||||
.approval-page.approval-page .approval-detail :is(
|
||||
.detail-hero,
|
||||
.progress-card,
|
||||
.detail-card,
|
||||
.detail-side-card,
|
||||
.detail-note,
|
||||
.detail-note-editor textarea,
|
||||
.detail-expense-table,
|
||||
.detail-attachment,
|
||||
.risk-list,
|
||||
.application-detail-fact,
|
||||
.application-budget-analysis__state,
|
||||
.application-budget-analysis__metrics article,
|
||||
.application-budget-analysis__summary,
|
||||
.application-budget-analysis__lists > div,
|
||||
.application-leader-opinion,
|
||||
.application-leader-opinion-event,
|
||||
.draft-blocking-note,
|
||||
.draft-blocking-issue,
|
||||
.expense-file-chip,
|
||||
.expense-editor-panel,
|
||||
.expense-editor-grid input,
|
||||
.expense-editor-grid select,
|
||||
.expense-total-under-table,
|
||||
.attachment-insight-pane,
|
||||
.attachment-source-pane,
|
||||
.attachment-preview-card,
|
||||
.attachment-preview-nav,
|
||||
.attachment-preview-close,
|
||||
.attachment-preview-alert,
|
||||
.attachment-preview-action,
|
||||
.attachment-preview-empty,
|
||||
.attachment-risk-card,
|
||||
.attachment-insight-section,
|
||||
.risk-summary-card,
|
||||
.risk-detail-card,
|
||||
.risk-advice-card,
|
||||
.risk-advice-meta > div,
|
||||
.risk-override-card,
|
||||
.risk-override-nav-btn,
|
||||
.system-row-lock,
|
||||
.system-attachment-note,
|
||||
.submit-confirm-summary,
|
||||
.smart-entry-btn,
|
||||
.icon-action,
|
||||
.inline-action
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
.approval-page.approval-page .ai-entry-modal :is(
|
||||
.detail-modal,
|
||||
.modal-card,
|
||||
.close-btn,
|
||||
.ai-chat-card,
|
||||
.ai-preview-card,
|
||||
.ai-chat-content,
|
||||
.ai-composer,
|
||||
.ai-composer-surface,
|
||||
.ai-tool-btn,
|
||||
.ai-upload-btn,
|
||||
.ai-send-btn,
|
||||
.preview-field,
|
||||
.preview-empty,
|
||||
.ai-preview-secondary,
|
||||
.ai-preview-primary,
|
||||
.modal-action
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
.approval-page.approval-page :is(
|
||||
.approval-opinion-field textarea,
|
||||
.return-reason-option,
|
||||
.return-reason-section textarea
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
.log-detail-page.log-detail-page :is(
|
||||
.detail-state,
|
||||
.detail-state button,
|
||||
.detail-hero,
|
||||
.refresh-btn,
|
||||
.detail-card,
|
||||
.info-grid > div,
|
||||
.feedback-grid > div,
|
||||
.trace-step,
|
||||
.code-block,
|
||||
.inline-empty,
|
||||
.detail-actions button,
|
||||
.knowledge-ingest-panel,
|
||||
.ingest-run-info,
|
||||
.info-item,
|
||||
.run-info-card,
|
||||
.run-stat-card,
|
||||
.graph-search,
|
||||
.graph-toolbar button,
|
||||
.graph-theater,
|
||||
.graph-stage,
|
||||
.graph-toolbar,
|
||||
.graph-inspector,
|
||||
.node-facts > div,
|
||||
.node-meta,
|
||||
.node-detail-panel,
|
||||
.detail-section,
|
||||
.evidence-document,
|
||||
.evidence-chunk,
|
||||
.evidence-empty,
|
||||
.node-evidence-card,
|
||||
.relation-detail-list button,
|
||||
.detail-empty
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
.skill-detail.skill-detail :is(
|
||||
.detail-hero,
|
||||
.detail-inline-state,
|
||||
.detail-loading-state,
|
||||
.review-note-block,
|
||||
.hero-stat,
|
||||
.detail-card,
|
||||
.side-card,
|
||||
.field input,
|
||||
.field textarea,
|
||||
.prompt-block textarea,
|
||||
.json-editor,
|
||||
.markdown-editor,
|
||||
.spreadsheet-editor-shell,
|
||||
.spreadsheet-workbench,
|
||||
.spreadsheet-change-center,
|
||||
.version-pair-card,
|
||||
.change-center-item,
|
||||
.change-record-preview,
|
||||
.spreadsheet-meta-strip span,
|
||||
.json-risk-editor-shell,
|
||||
.json-risk-generation-failure,
|
||||
.json-risk-meta-item,
|
||||
.json-risk-description-text,
|
||||
.json-risk-description-source,
|
||||
.json-risk-flow-card,
|
||||
.diagram-zoom-controls,
|
||||
.rule-spreadsheet-stage,
|
||||
.compare-panel,
|
||||
.compare-summary-grid article,
|
||||
.compare-sheet-list article,
|
||||
.change-detail-meta article,
|
||||
.compare-sheet-list span,
|
||||
.compare-table-wrap,
|
||||
.subtle-banner,
|
||||
.preview-mode-note,
|
||||
.prompt-block,
|
||||
.contract-panel,
|
||||
.version-row,
|
||||
.version-modal-summary div,
|
||||
.version-modal-note,
|
||||
.review-submit-test-state,
|
||||
.risk-rule-action-confirm,
|
||||
.risk-rule-action-note,
|
||||
.risk-rule-action-note textarea,
|
||||
.review-submit-form input:not([type='checkbox']),
|
||||
.review-submit-form select,
|
||||
.review-submit-form textarea,
|
||||
.review-submit-hint,
|
||||
.publish-summary,
|
||||
.empty-side-note,
|
||||
.back-action,
|
||||
.minor-action,
|
||||
.major-action,
|
||||
.mini-btn,
|
||||
.risk-level-menu,
|
||||
.risk-level-option
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
.skill-detail.skill-detail .rule-drawer-backdrop :is(
|
||||
.rule-drawer,
|
||||
.rule-drawer-state,
|
||||
.change-detail-meta article,
|
||||
.compare-panel,
|
||||
.compare-sheet-list span,
|
||||
.compare-table-wrap
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
.employee-center.employee-center .employee-detail :is(
|
||||
.detail-hero,
|
||||
.hero-profile,
|
||||
.hero-tag,
|
||||
.hero-stat,
|
||||
.detail-card,
|
||||
.side-card,
|
||||
.history-row,
|
||||
.field input,
|
||||
.field textarea,
|
||||
.role-option,
|
||||
.sync-card,
|
||||
.permission-pill,
|
||||
.detail-actions button,
|
||||
.detail-action-group
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
@@ -420,7 +420,7 @@
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 1320px;
|
||||
min-width: 1420px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
@@ -429,8 +429,9 @@ table {
|
||||
.col-created { width: 10%; }
|
||||
.col-stay { width: 9%; }
|
||||
.col-doc-type { width: 9%; }
|
||||
.col-scene { width: 10%; }
|
||||
.col-title { width: 18%; }
|
||||
.col-scene { width: 9%; }
|
||||
.col-initiator { width: 8%; }
|
||||
.col-title { width: 16%; }
|
||||
.col-amount { width: 9%; }
|
||||
.col-node { width: 12%; }
|
||||
.col-status { width: 8%; }
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
}
|
||||
|
||||
.console-toolbar {
|
||||
--logs-filter-control-height: 38px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.35fr) repeat(2, minmax(138px, 0.7fr)) auto;
|
||||
gap: 10px;
|
||||
@@ -103,9 +104,9 @@
|
||||
}
|
||||
|
||||
.field-input {
|
||||
min-height: 38px;
|
||||
min-height: var(--logs-filter-control-height);
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -130,10 +131,38 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.console-toolbar :deep(.enterprise-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.console-toolbar :deep(.el-select__wrapper) {
|
||||
min-height: var(--logs-filter-control-height);
|
||||
height: var(--logs-filter-control-height);
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px #d8e1eb inset;
|
||||
}
|
||||
|
||||
.console-toolbar :deep(.el-select__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px #b8c2d2 inset;
|
||||
}
|
||||
|
||||
.console-toolbar :deep(.el-select__wrapper.is-focused) {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--theme-primary) inset,
|
||||
0 0 0 3px var(--theme-focus-ring);
|
||||
}
|
||||
|
||||
.console-toolbar :deep(.el-select__placeholder),
|
||||
.console-toolbar :deep(.el-select__selected-item) {
|
||||
font-size: 13px;
|
||||
line-height: var(--logs-filter-control-height);
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
min-height: 38px;
|
||||
min-height: var(--logs-filter-control-height);
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d8e1eb;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-title h2,
|
||||
.preview-head h2 {
|
||||
@@ -86,9 +86,17 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.file-search input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.file-search input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.panel-tools {
|
||||
min-width: min(470px, 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.library-body {
|
||||
min-height: 0;
|
||||
@@ -101,11 +109,11 @@
|
||||
.folder-rail {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
border-right: 1px solid #edf2f7;
|
||||
padding-right: 12px;
|
||||
}
|
||||
border-right: 1px solid #edf2f7;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
min-height: 0;
|
||||
@@ -148,45 +156,38 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.folder-sync-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.new-folder-btn {
|
||||
.knowledge-sync-btn {
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb), .28);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-primary-light-9);
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.new-folder-btn.fixed {
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.knowledge-sync-btn:not(:disabled) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #1d4ed8;
|
||||
border-radius: 8px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.18);
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.knowledge-sync-btn:not(:disabled):hover {
|
||||
border-color: rgba(var(--theme-primary-rgb), 0.38);
|
||||
background: var(--theme-primary-light-9);
|
||||
color: var(--theme-primary-active);
|
||||
.knowledge-sync-btn:hover:not(:disabled) {
|
||||
border-color: #1e40af;
|
||||
background: #1d4ed8;
|
||||
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.24);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.folder-sync-meta {
|
||||
.knowledge-sync-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
border-color: #cbd5e1;
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.document-area {
|
||||
@@ -1179,6 +1180,12 @@ th {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel-tools,
|
||||
.file-search,
|
||||
.knowledge-sync-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.list-foot {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.field input {
|
||||
.field input,
|
||||
.field :deep(.el-select__wrapper) {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
@@ -116,7 +117,8 @@
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
.field input:focus,
|
||||
.field :deep(.el-select__wrapper.is-focused) {
|
||||
outline: none;
|
||||
border-color: var(--theme-primary);
|
||||
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
||||
|
||||
@@ -9,33 +9,52 @@
|
||||
</svg>
|
||||
</div>
|
||||
<strong class="brand-name">{{ displayCompanyName }}</strong>
|
||||
<button
|
||||
class="rail-collapse-btn"
|
||||
type="button"
|
||||
:aria-label="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||
:aria-expanded="!collapsed"
|
||||
@click="emit('toggle-collapse')"
|
||||
<ElTooltip
|
||||
:content="collapseTooltipContent"
|
||||
placement="right"
|
||||
effect="light"
|
||||
:show-after="180"
|
||||
:hide-after="0"
|
||||
:offset="12"
|
||||
popper-class="rail-tooltip-popper"
|
||||
>
|
||||
<i :class="collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left'"></i>
|
||||
</button>
|
||||
<button
|
||||
class="rail-collapse-btn"
|
||||
type="button"
|
||||
:aria-label="collapseTooltipContent"
|
||||
:aria-expanded="!collapsed"
|
||||
@click="emit('toggle-collapse')"
|
||||
>
|
||||
<i :class="collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left'"></i>
|
||||
</button>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
|
||||
<nav class="rail-nav" aria-label="功能导航">
|
||||
<button
|
||||
<ElTooltip
|
||||
v-for="item in decoratedNavItems"
|
||||
:key="item.id"
|
||||
class="nav-btn"
|
||||
:class="{ active: activeView === item.id }"
|
||||
type="button"
|
||||
:title="collapsed ? item.displayLabel : undefined"
|
||||
@click="emit('navigate', item.id)"
|
||||
:content="item.displayLabel"
|
||||
placement="right"
|
||||
effect="light"
|
||||
:disabled="!collapsed"
|
||||
:show-after="180"
|
||||
:hide-after="0"
|
||||
:offset="12"
|
||||
popper-class="rail-tooltip-popper"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="nav-btn"
|
||||
:class="{ active: activeView === item.id }"
|
||||
type="button"
|
||||
@click="emit('navigate', item.id)"
|
||||
>
|
||||
<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>
|
||||
</ElTooltip>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
@@ -69,19 +88,31 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="user-summary" tabindex="0" aria-label="用户信息" :title="collapsed ? displayUser.name : undefined">
|
||||
<span class="user-avatar">{{ displayUser.avatar }}</span>
|
||||
<span class="user-copy">
|
||||
<strong>{{ displayUser.name }}</strong>
|
||||
<span>{{ displayUser.role }}</span>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-up"></i>
|
||||
</div>
|
||||
<ElTooltip
|
||||
:content="userTooltipContent"
|
||||
placement="top"
|
||||
effect="light"
|
||||
:disabled="!collapsed"
|
||||
:show-after="180"
|
||||
:hide-after="0"
|
||||
:offset="10"
|
||||
popper-class="rail-tooltip-popper"
|
||||
>
|
||||
<div class="user-summary" tabindex="0" :aria-label="userTooltipContent">
|
||||
<span class="user-avatar">{{ displayUser.avatar }}</span>
|
||||
<span class="user-copy">
|
||||
<strong>{{ displayUser.name }}</strong>
|
||||
<span>{{ displayUser.role }}</span>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-up"></i>
|
||||
</div>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElTooltip } from 'element-plus'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||
@@ -154,6 +185,8 @@ const displayUser = computed(() => ({
|
||||
}))
|
||||
|
||||
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
||||
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
|
||||
const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · '))
|
||||
|
||||
const userMenuOpen = ref(false)
|
||||
let userMenuCloseTimer = null
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
class="detail-alert-pill"
|
||||
:class="alert.tone"
|
||||
>
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
|
||||
<span>{{ alert.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
<label
|
||||
v-for="option in options"
|
||||
:key="option.code"
|
||||
:class="['return-reason-option', { active: selectedCodes.includes(option.code) }]"
|
||||
:class="['return-reason-option', { active: isOptionActive(option.code) }]"
|
||||
>
|
||||
<input
|
||||
v-model="selectedCodes"
|
||||
type="checkbox"
|
||||
:type="application ? 'radio' : 'checkbox'"
|
||||
:name="application ? 'application-return-reason' : undefined"
|
||||
:checked="isOptionActive(option.code)"
|
||||
:value="option.code"
|
||||
:disabled="busy"
|
||||
@change="handleOptionChange"
|
||||
@change="handleOptionChange(option)"
|
||||
/>
|
||||
<i :class="option.icon"></i>
|
||||
<strong>{{ option.label }}</strong>
|
||||
@@ -99,6 +100,12 @@ const APPLICATION_RETURN_REASON_OPTIONS = [
|
||||
label: '前置材料需补充',
|
||||
icon: 'mdi mdi-file-document-plus-outline',
|
||||
defaultReason: '请补充会议通知、客户邀约、项目安排或其他能支撑申请必要性的材料。'
|
||||
},
|
||||
{
|
||||
code: 'application_other',
|
||||
label: '其他',
|
||||
icon: 'mdi mdi-pencil-box-outline',
|
||||
defaultReason: ''
|
||||
}
|
||||
]
|
||||
|
||||
@@ -117,12 +124,18 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close', 'confirm'])
|
||||
|
||||
const selectedCodes = ref([])
|
||||
const selectedApplicationCode = ref('')
|
||||
const reasonText = ref('')
|
||||
const touched = ref(false)
|
||||
const selectionTouched = ref(false)
|
||||
const lastAutoReason = ref('')
|
||||
|
||||
const options = computed(() => (props.application ? APPLICATION_RETURN_REASON_OPTIONS : CLAIM_RETURN_REASON_OPTIONS))
|
||||
const selectedReasonCodes = computed(() => (
|
||||
props.application
|
||||
? (selectedApplicationCode.value ? [selectedApplicationCode.value] : [])
|
||||
: selectedCodes.value
|
||||
))
|
||||
const dialogBadge = computed(() => (props.application ? '退回申请' : '退回单据'))
|
||||
const optionsTitle = computed(() => (props.application ? '退单选项' : '默认风险点'))
|
||||
const optionsAriaLabel = computed(() => (props.application ? '申请退单选项' : '默认退回风险点'))
|
||||
@@ -133,10 +146,10 @@ const reasonPlaceholder = computed(() => (
|
||||
))
|
||||
const trimmedReason = computed(() => reasonText.value.trim())
|
||||
const selectionError = computed(() => {
|
||||
if (!props.application || !selectionTouched.value || selectedCodes.value.length > 0) {
|
||||
if (!props.application || !selectionTouched.value || selectedReasonCodes.value.length > 0) {
|
||||
return ''
|
||||
}
|
||||
return '请选择至少一个退单选项,便于后续看板统计。'
|
||||
return '请选择一个退单选项,便于后续看板统计。'
|
||||
})
|
||||
const reasonError = computed(() => {
|
||||
if (!touched.value || trimmedReason.value.length >= 6) {
|
||||
@@ -159,6 +172,7 @@ watch(
|
||||
(open) => {
|
||||
if (open) {
|
||||
selectedCodes.value = []
|
||||
selectedApplicationCode.value = ''
|
||||
reasonText.value = ''
|
||||
touched.value = false
|
||||
selectionTouched.value = false
|
||||
@@ -167,25 +181,35 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedCodes, () => {
|
||||
if (!props.application) {
|
||||
return
|
||||
}
|
||||
|
||||
const defaultReason = selectedCodes.value
|
||||
.map((code) => options.value.find((option) => option.code === code)?.defaultReason || '')
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
function syncApplicationDefaultReason(option) {
|
||||
const defaultReason = String(option?.defaultReason || '').trim()
|
||||
const canAutoFill = !touched.value || !reasonText.value.trim() || reasonText.value === lastAutoReason.value
|
||||
if (canAutoFill) {
|
||||
reasonText.value = defaultReason
|
||||
}
|
||||
lastAutoReason.value = defaultReason
|
||||
})
|
||||
}
|
||||
|
||||
function handleOptionChange() {
|
||||
function isOptionActive(code) {
|
||||
return props.application ? selectedApplicationCode.value === code : selectedCodes.value.includes(code)
|
||||
}
|
||||
|
||||
function handleOptionChange(option) {
|
||||
selectionTouched.value = true
|
||||
|
||||
if (props.application) {
|
||||
selectedApplicationCode.value = option.code
|
||||
syncApplicationDefaultReason(option)
|
||||
return
|
||||
}
|
||||
|
||||
const selected = new Set(selectedCodes.value)
|
||||
if (selected.has(option.code)) {
|
||||
selected.delete(option.code)
|
||||
} else {
|
||||
selected.add(option.code)
|
||||
}
|
||||
selectedCodes.value = Array.from(selected)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -197,13 +221,13 @@ function handleClose() {
|
||||
function handleConfirm() {
|
||||
touched.value = true
|
||||
selectionTouched.value = true
|
||||
if ((props.application && selectedCodes.value.length === 0) || trimmedReason.value.length < 6 || props.busy) {
|
||||
if ((props.application && selectedReasonCodes.value.length === 0) || trimmedReason.value.length < 6 || props.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('confirm', {
|
||||
reason: trimmedReason.value,
|
||||
reason_codes: [...selectedCodes.value]
|
||||
reason_codes: [...selectedReasonCodes.value]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div v-else class="review-insight-title-row">
|
||||
<div class="review-insight-title-copy">
|
||||
<i v-if="!ui.activeReviewPayload && ui.isReviewFlowDrawer" :class="ui.reviewFlowDrawerIcon" class="title-icon"></i>
|
||||
<h3>{{ ui.reviewDrawerTitle }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +24,7 @@
|
||||
<p v-if="!ui.activeReviewPayload && !ui.isReviewFlowDrawer">{{ ui.currentInsight.summary }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="ui.activeReviewPayload || ui.isReviewFlowDrawer" class="review-insight-tools">
|
||||
<div v-if="ui.activeReviewPayload" class="review-insight-tools">
|
||||
<button
|
||||
v-if="ui.activeReviewPayload && ui.reviewOverviewDrawerAvailable"
|
||||
type="button"
|
||||
|
||||
@@ -14,21 +14,6 @@
|
||||
@close="emit('close')"
|
||||
@confirm="emit('confirm')"
|
||||
>
|
||||
<div class="submit-confirm-summary" aria-label="领导审批通过摘要">
|
||||
<div class="submit-confirm-row">
|
||||
<span>单据编号</span>
|
||||
<strong>{{ documentNo }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>当前节点</span>
|
||||
<strong>{{ node }}</strong>
|
||||
</div>
|
||||
<div class="submit-confirm-row">
|
||||
<span>{{ summaryLabel }}</span>
|
||||
<strong>{{ nextStage }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="approval-opinion-field">
|
||||
<span>
|
||||
{{ opinionTitle }}
|
||||
@@ -64,10 +49,6 @@ const props = defineProps({
|
||||
confirmText: { type: String, required: true },
|
||||
busyText: { type: String, required: true },
|
||||
busy: { type: Boolean, required: true },
|
||||
documentNo: { type: [String, Number], required: true },
|
||||
node: { type: String, default: '' },
|
||||
summaryLabel: { type: String, required: true },
|
||||
nextStage: { type: String, required: true },
|
||||
opinionTitle: { type: String, required: true },
|
||||
opinion: { type: String, default: '' },
|
||||
opinionPlaceholder: { type: String, default: '' },
|
||||
|
||||
293
web/src/components/travel/TravelRequestBudgetAnalysis.vue
Normal file
293
web/src/components/travel/TravelRequestBudgetAnalysis.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<section class="application-budget-analysis" aria-label="预算分析">
|
||||
<div class="application-budget-analysis__head">
|
||||
<span><i class="mdi mdi-chart-donut"></i>预算分析</span>
|
||||
<strong v-if="analysis && !loading">{{ scoreLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="application-budget-analysis__state">正在读取预算管控模型...</div>
|
||||
<div v-else-if="error" class="application-budget-analysis__state danger">{{ error }}</div>
|
||||
<div v-else-if="analysis" class="application-budget-analysis__body">
|
||||
<div class="application-budget-analysis__metrics">
|
||||
<article v-for="metric in metrics" :key="metric.key">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="application-budget-analysis__summary">
|
||||
<div :class="['application-budget-score', analysis.risk_level || 'low']">
|
||||
<span>{{ analysis.score }}</span>
|
||||
<em>综合评分</em>
|
||||
</div>
|
||||
<p>{{ analysis.summary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="application-budget-analysis__lists">
|
||||
<div>
|
||||
<span>规则依据</span>
|
||||
<ul>
|
||||
<li v-for="item in analysis.basis" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span>模型建议</span>
|
||||
<ul>
|
||||
<li v-for="item in analysis.suggestions" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { fetchExpenseClaimBudgetAnalysis } from '../../services/reimbursements.js'
|
||||
|
||||
const props = defineProps({
|
||||
claimId: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const analysis = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const scoreLabel = computed(() => {
|
||||
const labels = {
|
||||
recommended: '建议通过',
|
||||
caution: '谨慎通过',
|
||||
review: '需要复核',
|
||||
block: '不建议直接通过',
|
||||
reference: '参考口径'
|
||||
}
|
||||
return labels[String(analysis.value?.rating || '').trim()] || '模型建议'
|
||||
})
|
||||
|
||||
const metrics = computed(() => {
|
||||
const metric = analysis.value?.metrics || {}
|
||||
return [
|
||||
{ key: 'total', label: '当前预算额度', value: formatMoney(metric.total_amount) },
|
||||
{ key: 'ratio', label: '此次费用占预算', value: formatPercent(metric.claim_amount_ratio) },
|
||||
{ key: 'after', label: '审批后使用率', value: formatPercent(metric.after_usage_rate) },
|
||||
{ key: 'available', label: '当前可用预算', value: formatMoney(metric.available_amount) }
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.claimId,
|
||||
(claimId) => {
|
||||
loadAnalysis(claimId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function loadAnalysis(claimId) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
analysis.value = null
|
||||
error.value = ''
|
||||
if (!normalizedClaimId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
analysis.value = await fetchExpenseClaimBudgetAnalysis(normalizedClaimId)
|
||||
} catch (err) {
|
||||
error.value = err?.message || '预算分析加载失败,请稍后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) {
|
||||
return '待匹配'
|
||||
}
|
||||
return `${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) {
|
||||
return '0.00%'
|
||||
}
|
||||
return `${amount.toFixed(2)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.application-budget-analysis {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.application-budget-analysis__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.application-budget-analysis__head span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-budget-analysis__head i {
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.application-budget-analysis__head strong {
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(var(--theme-primary-rgb), .1);
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.application-budget-analysis__state {
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.application-budget-analysis__state.danger {
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.application-budget-analysis__body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.application-budget-analysis__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.application-budget-analysis__metrics article {
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.application-budget-analysis__metrics span,
|
||||
.application-budget-analysis__lists span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.application-budget-analysis__metrics strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-budget-analysis__summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.application-budget-analysis__summary p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.application-budget-score {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: #eef6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.application-budget-score.medium {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.application-budget-score.high {
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.application-budget-score span {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.application-budget-score em {
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.application-budget-analysis__lists {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.application-budget-analysis__lists > div {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.application-budget-analysis__lists ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.application-budget-analysis__metrics,
|
||||
.application-budget-analysis__lists {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.application-budget-analysis__metrics,
|
||||
.application-budget-analysis__lists,
|
||||
.application-budget-analysis__summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,6 +57,7 @@ const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
const APPLICATION_PROGRESS_LABELS = [
|
||||
'创建申请',
|
||||
'直属领导审批',
|
||||
'预算管理者审批',
|
||||
'审批完成'
|
||||
]
|
||||
|
||||
@@ -386,10 +387,13 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
const normalizedNode = String(workflowNode || '').trim()
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
|
||||
return 3
|
||||
}
|
||||
if (normalizedNode.includes('预算')) {
|
||||
return 2
|
||||
}
|
||||
if (
|
||||
@@ -437,15 +441,42 @@ function resolveApplicationApproverName(claim) {
|
||||
) || '直属领导'
|
||||
}
|
||||
|
||||
function resolveApplicationBudgetApproverName(claim) {
|
||||
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return resolveDisplayName(
|
||||
routeEvent?.next_approver_name,
|
||||
routeEvent?.nextApproverName,
|
||||
routeEvent?.budget_approver_name,
|
||||
routeEvent?.budgetApproverName
|
||||
) || 'P8预算监控者'
|
||||
}
|
||||
|
||||
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
|
||||
const normalizedLabel = normalizeText(label)
|
||||
const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
|
||||
if (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizeText(label) === '直属领导审批'
|
||||
&& normalizedLabel === '直属领导审批'
|
||||
&& (
|
||||
workflowNode.includes('直属领导')
|
||||
|| workflowNode.includes('领导审批')
|
||||
|| workflowNode.includes('部门负责人')
|
||||
|| workflowNode.includes('负责人审批')
|
||||
)
|
||||
) {
|
||||
return `等待 ${resolveApplicationApproverName(claim)} 批复`
|
||||
}
|
||||
|
||||
if (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '预算管理者审批'
|
||||
&& workflowNode.includes('预算')
|
||||
) {
|
||||
return `等待 ${resolveApplicationBudgetApproverName(claim)} 批复`
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
@@ -471,7 +502,7 @@ function findApprovalEventForStep(claim, label) {
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
if (!['manual_approval', 'finance_approval'].includes(source)) {
|
||||
if (!['manual_approval', 'budget_approval', 'finance_approval'].includes(source)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -482,10 +513,19 @@ function findApprovalEventForStep(claim, label) {
|
||||
return (
|
||||
previousStage.includes('直属领导')
|
||||
|| previousStage.includes('领导审批')
|
||||
|| nextStage.includes('预算')
|
||||
|| nextStage.includes('财务')
|
||||
)
|
||||
}
|
||||
|
||||
if (stepLabel === '预算管理者审批') {
|
||||
return (
|
||||
source === 'budget_approval'
|
||||
|| previousStage.includes('预算')
|
||||
|| nextStage.includes('审批完成')
|
||||
)
|
||||
}
|
||||
|
||||
if (stepLabel === '财务审批') {
|
||||
return (
|
||||
previousStage.includes('财务')
|
||||
@@ -557,15 +597,16 @@ function buildCompletedStepMeta(claim, label) {
|
||||
return buildProgressStepMeta('AI预审通过', reviewedAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '直属领导审批' || stepLabel === '财务审批') {
|
||||
if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
|
||||
if (approvalEvent) {
|
||||
const operator = resolveDisplayName(
|
||||
approvalEvent.operator,
|
||||
approvalEvent.operator_name,
|
||||
approvalEvent.operatorName,
|
||||
stepLabel === '直属领导审批' ? claim?.manager_name : ''
|
||||
) || (stepLabel === '财务审批' ? '财务' : '直属领导')
|
||||
stepLabel === '直属领导审批' ? claim?.manager_name : '',
|
||||
stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : ''
|
||||
) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算监控者' : '直属领导')
|
||||
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
|
||||
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
|
||||
}
|
||||
@@ -626,6 +667,10 @@ function resolveCurrentStepStartedAt(claim, label) {
|
||||
if (stepLabel === '直属领导审批') {
|
||||
return claim?.submitted_at || claim?.updated_at || claim?.created_at
|
||||
}
|
||||
if (stepLabel === '预算管理者审批') {
|
||||
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '财务审批') {
|
||||
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
|
||||
@@ -9,6 +9,7 @@ import router from './router/index.js'
|
||||
import { installThemeSkin } from './composables/useThemeSkin.js'
|
||||
import { installSessionNavigation } from './composables/useSystemState.js'
|
||||
import './assets/styles/element-plus-theme.css'
|
||||
import './assets/styles/detail-page-corners.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ export function fetchExpenseClaimDetail(claimId) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
|
||||
}
|
||||
|
||||
export function fetchExpenseClaimBudgetAnalysis(claimId) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/budget-analysis`)
|
||||
}
|
||||
|
||||
export function updateExpenseClaim(claimId, payload = {}) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -19,9 +19,10 @@ const VIEW_ROLE_RULES = {
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
const CLAIM_BUDGET_APPROVAL_GRADE = 'P8'
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
@@ -55,6 +56,25 @@ function identityIntersects(leftValues, rightValues) {
|
||||
return leftValues.some((item) => rightSet.has(item))
|
||||
}
|
||||
|
||||
function normalizedGrade(user) {
|
||||
return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
function departmentIntersects(request, user) {
|
||||
const requestDepartments = collectIdentityNames(
|
||||
request?.dept,
|
||||
request?.departmentName,
|
||||
request?.department_name
|
||||
)
|
||||
const currentDepartments = collectIdentityNames(
|
||||
user?.department,
|
||||
user?.departmentName,
|
||||
user?.department_name
|
||||
)
|
||||
|
||||
return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments)
|
||||
}
|
||||
|
||||
function hasPlatformAdminIdentity(user) {
|
||||
if (!user) {
|
||||
return false
|
||||
@@ -130,6 +150,25 @@ export function canApproveLeaderExpenseClaims(user) {
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canApproveBudgetExpenseApplications(user, request = null) {
|
||||
if (isPlatformAdminUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const roleCodes = normalizedRoleCodes(user)
|
||||
if (roleCodes.includes('executive')) {
|
||||
return true
|
||||
}
|
||||
if (!roleCodes.includes('budget_monitor')) {
|
||||
return false
|
||||
}
|
||||
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
|
||||
return false
|
||||
}
|
||||
|
||||
return request ? departmentIntersects(request, user) : true
|
||||
}
|
||||
|
||||
export function isCurrentRequestApplicant(request, user) {
|
||||
const applicantNames = collectIdentityNames(
|
||||
request?.person,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import {
|
||||
canApproveBudgetExpenseApplications,
|
||||
canApproveLeaderExpenseClaims,
|
||||
isCurrentDirectManagerForRequest,
|
||||
isCurrentRequestApplicant,
|
||||
@@ -17,6 +18,10 @@ export function canProcessApprovalRequest(request, currentUser) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.includes('预算')) {
|
||||
return canApproveBudgetExpenseApplications(currentUser, request)
|
||||
}
|
||||
|
||||
const isLeaderApprovalNode = (
|
||||
node.includes('直属领导')
|
||||
|| node.includes('领导审批')
|
||||
|
||||
@@ -115,10 +115,69 @@ export function hasPendingInfo(request) {
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveDetailAlertTone(request) {
|
||||
if (request?.approvalKey === 'completed') return 'success'
|
||||
if (request?.approvalKey === 'rejected') return 'danger'
|
||||
return 'warning'
|
||||
function getRiskFlags(request) {
|
||||
if (Array.isArray(request?.riskFlags)) {
|
||||
return request.riskFlags
|
||||
}
|
||||
if (Array.isArray(request?.risk_flags_json)) {
|
||||
return request.risk_flags_json
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) && nextValue > 0 ? Math.floor(nextValue) : 0
|
||||
}
|
||||
|
||||
function resolveSlaReminderCount(request) {
|
||||
const directCount = [
|
||||
request?.slaReminderCount,
|
||||
request?.sla_reminder_count,
|
||||
request?.slaUrgeCount,
|
||||
request?.sla_urge_count,
|
||||
request?.urgeCount,
|
||||
request?.urge_count,
|
||||
request?.reminderCount,
|
||||
request?.reminder_count
|
||||
].reduce((max, value) => Math.max(max, parseNonNegativeInteger(value)), 0)
|
||||
|
||||
if (directCount > 0) {
|
||||
return directCount
|
||||
}
|
||||
|
||||
return getRiskFlags(request).reduce((count, flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return count
|
||||
}
|
||||
|
||||
const explicitCount = [
|
||||
flag.slaReminderCount,
|
||||
flag.sla_reminder_count,
|
||||
flag.slaUrgeCount,
|
||||
flag.sla_urge_count,
|
||||
flag.urgeCount,
|
||||
flag.urge_count,
|
||||
flag.reminderCount,
|
||||
flag.reminder_count
|
||||
].reduce((max, value) => Math.max(max, parseNonNegativeInteger(value)), 0)
|
||||
|
||||
if (explicitCount > 0) {
|
||||
return count + explicitCount
|
||||
}
|
||||
|
||||
const signal = [
|
||||
flag.source,
|
||||
flag.event_type,
|
||||
flag.eventType,
|
||||
flag.action,
|
||||
flag.type,
|
||||
flag.label,
|
||||
flag.message
|
||||
].join(' ')
|
||||
|
||||
return /sla|remind|reminder|urge|催单/i.test(signal) ? count + 1 : count
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function buildDetailAlerts(request) {
|
||||
@@ -127,11 +186,13 @@ export function buildDetailAlerts(request) {
|
||||
}
|
||||
|
||||
const alerts = []
|
||||
const nodeLabel = String(request.node || request.approval || '').trim()
|
||||
const slaReminderCount = resolveSlaReminderCount(request)
|
||||
|
||||
if (nodeLabel) {
|
||||
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
|
||||
}
|
||||
alerts.push({
|
||||
label: `SLA 催单次数 ${slaReminderCount}`,
|
||||
tone: slaReminderCount > 0 ? 'warning' : 'neutral',
|
||||
icon: 'mdi mdi-bell-ring-outline'
|
||||
})
|
||||
|
||||
if (hasMissingAttachment(request)) {
|
||||
alerts.push({ label: '缺少票据', tone: 'warning' })
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
<col v-if="showStayTimeColumn" class="col-stay">
|
||||
<col class="col-doc-type">
|
||||
<col class="col-scene">
|
||||
<col class="col-initiator">
|
||||
<col class="col-title">
|
||||
<col class="col-amount">
|
||||
<col class="col-node">
|
||||
@@ -184,6 +185,7 @@
|
||||
<th v-if="showStayTimeColumn">停留时间</th>
|
||||
<th>单据类型</th>
|
||||
<th>费用场景</th>
|
||||
<th>发起人</th>
|
||||
<th>事项</th>
|
||||
<th>金额</th>
|
||||
<th>当前环节</th>
|
||||
@@ -201,6 +203,7 @@
|
||||
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
|
||||
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
|
||||
<td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
|
||||
<td>{{ row.initiatorName }}</td>
|
||||
<td>{{ row.reason }}</td>
|
||||
<td>{{ row.amountDisplay }}</td>
|
||||
<td>{{ row.node }}</td>
|
||||
@@ -437,6 +440,7 @@ const filteredRows = computed(() => {
|
||||
row.documentNo,
|
||||
row.documentTypeLabel,
|
||||
row.typeLabel,
|
||||
row.initiatorName,
|
||||
row.reason,
|
||||
row.node,
|
||||
row.statusLabel
|
||||
@@ -538,6 +542,16 @@ function buildDocumentRow(request, options = {}) {
|
||||
const documentTypeLabel =
|
||||
normalized.documentTypeLabel
|
||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||
const initiatorName = String(
|
||||
normalized.person
|
||||
|| normalized.employeeName
|
||||
|| normalized.profileName
|
||||
|| normalized.applicant
|
||||
|| request?.employee_name
|
||||
|| request?.employeeName
|
||||
|| request?.person
|
||||
|| ''
|
||||
).trim() || '待补充'
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
@@ -547,6 +561,7 @@ function buildDocumentRow(request, options = {}) {
|
||||
documentTypeLabel,
|
||||
claimId,
|
||||
documentNo,
|
||||
initiatorName,
|
||||
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
statusGroup,
|
||||
statusLabel,
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
v-model="systemLevelFilter"
|
||||
:options="systemLevelFilterOptions"
|
||||
placeholder="全部"
|
||||
size="small"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -41,7 +40,6 @@
|
||||
v-model="systemEventTypeFilter"
|
||||
:options="systemEventTypeFilterOptions"
|
||||
placeholder="全部"
|
||||
size="small"
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -8,10 +8,21 @@
|
||||
<h2>文档库 / 文件夹</h2>
|
||||
<p>默认展示文件列表,点击具体文件后以弹窗方式展开预览。</p>
|
||||
</div>
|
||||
<label class="file-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
|
||||
</label>
|
||||
<div class="panel-tools">
|
||||
<label class="file-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
|
||||
</label>
|
||||
<button
|
||||
class="knowledge-sync-btn"
|
||||
type="button"
|
||||
:disabled="!canTriggerKnowledgeSync"
|
||||
@click="handleKnowledgeSync"
|
||||
>
|
||||
<i :class="syncingFolder ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-book-sync-outline'"></i>
|
||||
<span>{{ knowledgeSyncButtonLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="library-body">
|
||||
@@ -30,19 +41,7 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="folder-sync-block">
|
||||
<button
|
||||
class="new-folder-btn fixed knowledge-sync-btn"
|
||||
type="button"
|
||||
:disabled="!canTriggerKnowledgeSync"
|
||||
@click="handleKnowledgeSync"
|
||||
>
|
||||
<i :class="syncingFolder ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-book-sync-outline'"></i>
|
||||
<span>{{ knowledgeSyncButtonLabel }}</span>
|
||||
</button>
|
||||
<p class="folder-sync-meta">{{ knowledgeSyncHint }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
|
||||
<section class="document-area" :class="{ 'read-only': !isAdmin }">
|
||||
<div
|
||||
|
||||
@@ -177,6 +177,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TravelRequestBudgetAnalysis
|
||||
v-if="showBudgetAnalysis"
|
||||
:claim-id="request.claimId"
|
||||
/>
|
||||
|
||||
<div v-if="showApplicationLeaderOpinion" class="application-leader-opinion">
|
||||
<div class="application-leader-opinion-head">
|
||||
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
|
||||
@@ -760,10 +765,6 @@
|
||||
:confirm-text="approveConfirmText"
|
||||
:busy-text="approveBusyText"
|
||||
:busy="approveBusy"
|
||||
:document-no="request.documentNo || request.id"
|
||||
:node="request.node"
|
||||
:summary-label="approvalConfirmSummaryLabel"
|
||||
:next-stage="approvalNextStage"
|
||||
:opinion-title="approvalOpinionTitle"
|
||||
v-model:opinion="leaderOpinion"
|
||||
:opinion-placeholder="approvalOpinionPlaceholder"
|
||||
|
||||
@@ -372,7 +372,7 @@ function matchKeyword(employee, keyword) {
|
||||
return true
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
const fields = [
|
||||
employee.name,
|
||||
employee.employeeNo,
|
||||
employee.department,
|
||||
@@ -380,9 +380,13 @@ function matchKeyword(employee, keyword) {
|
||||
employee.email,
|
||||
employee.manager,
|
||||
employee.financeOwner,
|
||||
employee.syncState,
|
||||
...(employee.roles || [])
|
||||
employee.syncState
|
||||
]
|
||||
|
||||
const roles = Array.isArray(employee.roles) ? employee.roles : []
|
||||
|
||||
const haystack = [...fields, ...roles]
|
||||
.map((val) => String(val || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
@@ -167,31 +167,15 @@ export default {
|
||||
}
|
||||
return stats
|
||||
})
|
||||
const knowledgeSyncButtonLabel = computed(() => {
|
||||
if (syncingFolder.value || activeFolderIngestStats.value.syncing > 0) {
|
||||
return '归纳中...'
|
||||
}
|
||||
return '知识归纳'
|
||||
})
|
||||
const knowledgeSyncHint = computed(() => {
|
||||
const stats = activeFolderIngestStats.value
|
||||
if (!activeFolder.value) {
|
||||
return '请选择一个固定知识目录后再触发归纳。'
|
||||
}
|
||||
if (!stats.total) {
|
||||
return '当前目录暂无文档,上传后即可进行知识归纳。'
|
||||
}
|
||||
if (stats.syncing > 0) {
|
||||
return `当前目录有 ${stats.syncing} 份文档正在归纳,完成后会自动刷新状态。`
|
||||
}
|
||||
if (stats.pending > 0 || stats.failed > 0) {
|
||||
return `当前目录待归纳 ${stats.pending} 份,需重试 ${stats.failed} 份。`
|
||||
}
|
||||
return `当前目录 ${stats.ingested} 份文档已归纳,可手动触发一次增量检查。`
|
||||
})
|
||||
const canTriggerKnowledgeSync = computed(
|
||||
() =>
|
||||
isAdmin.value
|
||||
const knowledgeSyncButtonLabel = computed(() => {
|
||||
if (syncingFolder.value || activeFolderIngestStats.value.syncing > 0) {
|
||||
return '归纳中...'
|
||||
}
|
||||
return '知识归纳'
|
||||
})
|
||||
const canTriggerKnowledgeSync = computed(
|
||||
() =>
|
||||
isAdmin.value
|
||||
&& Boolean(activeFolder.value)
|
||||
&& activeFolderIngestStats.value.total > 0
|
||||
&& !syncingFolder.value
|
||||
@@ -445,11 +429,11 @@ export default {
|
||||
|
||||
syncingFolder.value = true
|
||||
try {
|
||||
const payload = await syncKnowledgeLibrary({
|
||||
folder: activeFolder.value,
|
||||
documentIds: [],
|
||||
force: false
|
||||
})
|
||||
const payload = await syncKnowledgeLibrary({
|
||||
folder: activeFolder.value,
|
||||
documentIds: [],
|
||||
force: true
|
||||
})
|
||||
|
||||
const queuedIds = Array.isArray(payload?.document_ids) ? payload.document_ids : []
|
||||
for (const documentId of queuedIds) {
|
||||
@@ -461,8 +445,9 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
toast(payload?.summary || '\u77e5\u8bc6\u5f52\u7eb3\u4efb\u52a1\u5df2\u63d0\u4ea4\u3002')
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
const runHint = payload?.agent_run_id ? `日志编号:${payload.agent_run_id}` : ''
|
||||
toast([payload?.summary || '知识归纳任务已提交。', runHint].filter(Boolean).join(' '))
|
||||
} catch (error) {
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
toast(error.message || '\u77e5\u8bc6\u5f52\u7eb3\u89e6\u53d1\u5931\u8d25\u3002')
|
||||
@@ -647,9 +632,8 @@ export default {
|
||||
handleFileInput,
|
||||
handleKnowledgeSync,
|
||||
isAdmin,
|
||||
knowledgeSyncButtonLabel,
|
||||
knowledgeSyncHint,
|
||||
loading,
|
||||
knowledgeSyncButtonLabel,
|
||||
loading,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
pageSizes,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToast } from '../../composables/useToast.js'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
|
||||
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
||||
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
||||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
canApproveBudgetExpenseApplications,
|
||||
canApproveLeaderExpenseClaims,
|
||||
canDeleteArchivedExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
@@ -369,6 +371,7 @@ export default {
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect,
|
||||
TravelRequestApprovalDialog,
|
||||
TravelRequestBudgetAnalysis,
|
||||
TravelRequestDeleteDialog,
|
||||
TravelRequestReturnDialog
|
||||
},
|
||||
@@ -490,6 +493,10 @@ export default {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '财务审批'
|
||||
})
|
||||
const isBudgetApprovalStage = computed(() => {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '预算管理者审批'
|
||||
})
|
||||
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
|
||||
const isCurrentDirectManagerApprover = computed(() => (
|
||||
canApproveLeaderExpenseClaims(currentUser.value)
|
||||
@@ -501,6 +508,18 @@ export default {
|
||||
&& isFinanceUser(currentUser.value)
|
||||
&& !isCurrentApplicant.value
|
||||
))
|
||||
const canProcessBudgetApprovalStage = computed(() => (
|
||||
isApplicationDocument.value
|
||||
&& isBudgetApprovalStage.value
|
||||
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
||||
&& !isCurrentApplicant.value
|
||||
))
|
||||
const showBudgetAnalysis = computed(() => (
|
||||
isApplicationDocument.value
|
||||
&& isBudgetApprovalStage.value
|
||||
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
||||
&& !isCurrentApplicant.value
|
||||
))
|
||||
const canReturnRequest = computed(() => {
|
||||
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
|
||||
return false
|
||||
@@ -508,6 +527,9 @@ export default {
|
||||
if (isDirectManagerApprovalStage.value) {
|
||||
return isCurrentDirectManagerApprover.value
|
||||
}
|
||||
if (isBudgetApprovalStage.value) {
|
||||
return canProcessBudgetApprovalStage.value
|
||||
}
|
||||
return canProcessFinanceApprovalStage.value
|
||||
})
|
||||
const canApproveRequest = computed(() =>
|
||||
@@ -520,6 +542,7 @@ export default {
|
||||
&& isCurrentDirectManagerApprover.value
|
||||
)
|
||||
|| canProcessFinanceApprovalStage.value
|
||||
|| canProcessBudgetApprovalStage.value
|
||||
)
|
||||
)
|
||||
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
||||
@@ -536,39 +559,43 @@ export default {
|
||||
isApplicationDocument.value
|
||||
&& hasLeaderApprovalEvents.value
|
||||
))
|
||||
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
|
||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||||
const requiresApprovalOpinion = computed(() => false)
|
||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见'))
|
||||
const approvalOpinionPlaceholder = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||||
}
|
||||
if (isApplicationDocument.value) {
|
||||
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。'
|
||||
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
|
||||
}
|
||||
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
||||
return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。'
|
||||
})
|
||||
const approvalOpinionHint = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '审核通过后将进入归档入账。'
|
||||
}
|
||||
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。'
|
||||
if (isBudgetApprovalStage.value) {
|
||||
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
|
||||
}
|
||||
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后会流转至预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
|
||||
})
|
||||
const approvalConfirmBadge = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '财务终审'
|
||||
}
|
||||
return isBudgetApprovalStage.value ? '预算审核' : '领导审批'
|
||||
})
|
||||
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
||||
const approvalConfirmDescription = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||||
}
|
||||
if (isApplicationDocument.value) {
|
||||
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。'
|
||||
return isBudgetApprovalStage.value
|
||||
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
|
||||
: '确认后该申请单会完成直属领导审批,并流转给预算管理者进一步审核。'
|
||||
}
|
||||
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||
})
|
||||
const approvalNextStage = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '归档入账'
|
||||
}
|
||||
return isApplicationDocument.value ? '报销草稿' : '财务审批'
|
||||
})
|
||||
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
|
||||
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
|
||||
const approveConfirmTitle = computed(() => (
|
||||
@@ -581,15 +608,14 @@ export default {
|
||||
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
|
||||
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
|
||||
))
|
||||
const approvalConfirmSummaryLabel = computed(() => (
|
||||
isApplicationDocument.value ? '生成结果' : '下一节点'
|
||||
))
|
||||
const approvalSuccessToast = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return `${request.value.id} 已完成财务终审,进入归档入账。`
|
||||
}
|
||||
return isApplicationDocument.value
|
||||
? `${request.value.id} 已确认审核,正在生成报销草稿。`
|
||||
? isBudgetApprovalStage.value
|
||||
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
|
||||
: `${request.value.id} 已确认审核,已流转至预算管理者审批。`
|
||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||
})
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
@@ -1751,15 +1777,10 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
||||
toast('请先填写领导意见,填写后才能确认审核。')
|
||||
return
|
||||
}
|
||||
|
||||
approveBusy.value = true
|
||||
try {
|
||||
const responsePayload = await approveExpenseClaim(request.value.claimId, {
|
||||
opinion: leaderOpinion.value.trim()
|
||||
opinion: leaderOpinion.value.trim() || '同意'
|
||||
})
|
||||
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
|
||||
approveConfirmDialogOpen.value = false
|
||||
@@ -1805,7 +1826,7 @@ export default {
|
||||
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint,
|
||||
approvalConfirmDescription, approvalOpinionHint,
|
||||
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
||||
applicationDetailFactItems,
|
||||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||||
@@ -1836,6 +1857,7 @@ export default {
|
||||
requiresApprovalOpinion,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||
showBudgetAnalysis,
|
||||
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
|
||||
263
web/src/views/scripts/auditViewDigitalEmployeeModel.js
Normal file
263
web/src/views/scripts/auditViewDigitalEmployeeModel.js
Normal file
@@ -0,0 +1,263 @@
|
||||
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
daily_risk_scan: '每日风险巡检',
|
||||
global_risk_scan: '全局风险巡检',
|
||||
weekly_ar_summary: '周度应收账龄汇总',
|
||||
weekly_expense_report: '周度费用洞察',
|
||||
rule_review_digest: '规则待审摘要',
|
||||
knowledge_index_sync: '知识库归集',
|
||||
x_financial_callback: '任务回调上报'
|
||||
}
|
||||
|
||||
const CONTENT_LABELS = {
|
||||
task_type: '技能类型',
|
||||
schedule: '执行计划',
|
||||
cron: '调度表达式',
|
||||
folder: '归集范围',
|
||||
changed_only: '仅处理变更',
|
||||
force: '强制重建',
|
||||
index_engine: '索引引擎',
|
||||
callback_type: '回调类型',
|
||||
status: '回写状态',
|
||||
summary: '结果摘要'
|
||||
}
|
||||
|
||||
const HIDDEN_CONTENT_KEYS = new Set([
|
||||
'agent',
|
||||
'target_agent',
|
||||
'callback_token',
|
||||
'token',
|
||||
'api_key',
|
||||
'authorization'
|
||||
])
|
||||
|
||||
export function normalizeDigitalEmployeeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function sanitizeDigitalEmployeeText(value, fallback = '') {
|
||||
const text = normalizeDigitalEmployeeText(value)
|
||||
.replace(/hermes/gi, '数字员工')
|
||||
.replace(/赫尔墨斯/g, '数字员工')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
export function sanitizeDigitalEmployeeName(value, fallback = '数字员工技能') {
|
||||
const text = sanitizeDigitalEmployeeText(value, fallback)
|
||||
.replace(/^数字员工[\s·::-]*/i, '')
|
||||
.trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
export function parseDigitalEmployeeContent(value) {
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeTaskType(source = {}, content = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
const raw =
|
||||
normalizeDigitalEmployeeText(content.task_type) ||
|
||||
normalizeDigitalEmployeeText(config.task_type) ||
|
||||
normalizeDigitalEmployeeText(source.task_type) ||
|
||||
normalizeDigitalEmployeeText(source.code).replace(/^task\.hermes\./i, '')
|
||||
return raw.replace(/[-.]/g, '_')
|
||||
}
|
||||
|
||||
export function isDigitalEmployeeAsset(source = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
const haystack = [
|
||||
source.asset_type,
|
||||
source.code,
|
||||
source.name,
|
||||
source.description,
|
||||
config.agent,
|
||||
config.target_agent,
|
||||
config.worker,
|
||||
config.runtime_agent
|
||||
]
|
||||
.map((item) => normalizeDigitalEmployeeText(item).toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
normalizeDigitalEmployeeText(source.asset_type) === 'task' &&
|
||||
(haystack.includes(DIGITAL_EMPLOYEE_AGENT) || haystack.includes('task.hermes.'))
|
||||
)
|
||||
}
|
||||
|
||||
export function formatDigitalEmployeeCron(value) {
|
||||
const raw = normalizeDigitalEmployeeText(value)
|
||||
if (!raw) {
|
||||
return '手动触发'
|
||||
}
|
||||
|
||||
const parts = raw.split(/\s+/)
|
||||
if (parts.length < 5) {
|
||||
return sanitizeDigitalEmployeeText(raw)
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
const hourNumber = Number(hour)
|
||||
const minuteNumber = Number(minute)
|
||||
const timeLabel =
|
||||
Number.isFinite(hourNumber) && Number.isFinite(minuteNumber)
|
||||
? `${String(hourNumber).padStart(2, '0')}:${String(minuteNumber).padStart(2, '0')}`
|
||||
: `${hour}:${minute}`
|
||||
|
||||
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return `每天 ${timeLabel}`
|
||||
}
|
||||
|
||||
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
|
||||
const weekdayLabels = {
|
||||
'0': '周日',
|
||||
'1': '周一',
|
||||
'2': '周二',
|
||||
'3': '周三',
|
||||
'4': '周四',
|
||||
'5': '周五',
|
||||
'6': '周六',
|
||||
'7': '周日'
|
||||
}
|
||||
return `每${weekdayLabels[dayOfWeek] || `周${dayOfWeek}`} ${timeLabel}`
|
||||
}
|
||||
|
||||
return sanitizeDigitalEmployeeText(raw)
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeSchedule(source = {}, content = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
const raw =
|
||||
normalizeDigitalEmployeeText(content.schedule) ||
|
||||
normalizeDigitalEmployeeText(config.cron) ||
|
||||
normalizeDigitalEmployeeText(config.schedule) ||
|
||||
normalizeDigitalEmployeeText(config.cron_expression)
|
||||
return {
|
||||
value: raw,
|
||||
label: formatDigitalEmployeeCron(raw)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeEnabled(source = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
if (config.enabled === false || config.is_enabled === false) {
|
||||
return false
|
||||
}
|
||||
if (source.enabled === false || source.is_enabled === false) {
|
||||
return false
|
||||
}
|
||||
return normalizeDigitalEmployeeText(source.status || 'active') === 'active'
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeDisplayCode(source = {}, content = {}) {
|
||||
const taskType = resolveDigitalEmployeeTaskType(source, content)
|
||||
return taskType ? `digital.${taskType}` : 'digital.skill'
|
||||
}
|
||||
|
||||
function formatDigitalEmployeeValue(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeDigitalEmployeeText(item)).filter(Boolean).join('、') || '-'
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return sanitizeDigitalEmployeeText(JSON.stringify(value, null, 2))
|
||||
}
|
||||
return sanitizeDigitalEmployeeText(value, '-')
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeContentRows(content = {}) {
|
||||
return Object.entries(content)
|
||||
.filter(([key]) => !HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase()))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: CONTENT_LABELS[key] || key,
|
||||
value: formatDigitalEmployeeValue(value)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeContentPreview(content = {}) {
|
||||
const visiblePayload = {}
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
if (HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
visiblePayload[key] = value
|
||||
}
|
||||
return sanitizeDigitalEmployeeText(JSON.stringify(visiblePayload, null, 2))
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeListMeta(source = {}) {
|
||||
const content = parseDigitalEmployeeContent(source.current_version_content)
|
||||
const taskType = resolveDigitalEmployeeTaskType(source, content)
|
||||
const schedule = resolveDigitalEmployeeSchedule(source, content)
|
||||
const enabled = resolveDigitalEmployeeEnabled(source)
|
||||
const fallbackName = TASK_TYPE_LABELS[taskType] || '数字员工技能'
|
||||
|
||||
return {
|
||||
name: sanitizeDigitalEmployeeName(source.name, fallbackName),
|
||||
code: resolveDigitalEmployeeDisplayCode(source, content),
|
||||
summary: sanitizeDigitalEmployeeText(source.description, '面向后台自动执行的数字员工技能。'),
|
||||
category: '数字员工',
|
||||
owner: sanitizeDigitalEmployeeText(source.owner, '平台运营'),
|
||||
reviewer: sanitizeDigitalEmployeeText(source.reviewer, '系统'),
|
||||
scope: schedule.label,
|
||||
scheduleLabel: schedule.label,
|
||||
executionMode: schedule.value ? '定时执行' : '手动触发',
|
||||
enabled,
|
||||
enabledLabel: enabled ? '已启动' : '未启动',
|
||||
enabledTone: enabled ? 'success' : 'disabled',
|
||||
taskType
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeDetailMeta(source = {}) {
|
||||
const content = parseDigitalEmployeeContent(source.current_version_content)
|
||||
const listMeta = buildDigitalEmployeeListMeta({
|
||||
...source,
|
||||
current_version_content: content
|
||||
})
|
||||
const schedule = resolveDigitalEmployeeSchedule(source, content)
|
||||
const contentRows = buildDigitalEmployeeContentRows(content)
|
||||
|
||||
return {
|
||||
...listMeta,
|
||||
rawCode: normalizeDigitalEmployeeText(source.code),
|
||||
description: sanitizeDigitalEmployeeText(
|
||||
source.description,
|
||||
'该技能由后台数字员工按计划执行,并把结果沉淀到对应业务资产或运行日志中。'
|
||||
),
|
||||
contentRows,
|
||||
contentPreview: buildDigitalEmployeeContentPreview(content),
|
||||
scheduleRows: [
|
||||
{ label: '执行计划', value: schedule.label },
|
||||
{ label: '调度表达式', value: schedule.value || '手动触发' },
|
||||
{ label: '启动状态', value: listMeta.enabledLabel, tone: listMeta.enabledTone },
|
||||
{ label: '执行方式', value: listMeta.executionMode }
|
||||
],
|
||||
overviewRows: [
|
||||
{ label: '能力编号', value: listMeta.code },
|
||||
{ label: '业务归口', value: listMeta.owner },
|
||||
{ label: '当前版本', value: source.working_version || source.current_version || '-' },
|
||||
{ label: '最近更新', value: source.updated_at || '-' }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,32 @@ export const TAB_META = {
|
||||
...TYPE_META.mcp,
|
||||
typeKey: 'mcp',
|
||||
badgeTone: 'amber'
|
||||
},
|
||||
digitalWorkers: {
|
||||
assetType: 'task',
|
||||
typeKey: 'digitalWorkers',
|
||||
label: '数字员工',
|
||||
typeLabel: '数字员工',
|
||||
createButtonLabel: '数字员工已接入',
|
||||
hintText: '归集后台自动执行的数字员工技能,可查看技能内容、执行计划、启动状态和最近版本。',
|
||||
searchPlaceholder: '搜索数字员工技能、编号、执行计划或维护人',
|
||||
showMetricColumn: true,
|
||||
showRuntimeColumn: true,
|
||||
showVersionColumn: true,
|
||||
showStatusColumn: true,
|
||||
showEnabledColumn: true,
|
||||
tableColumns: {
|
||||
name: '技能名称',
|
||||
category: '归集标签',
|
||||
owner: '维护归口',
|
||||
scope: '执行计划',
|
||||
runtime: '触发方式',
|
||||
version: '当前版本',
|
||||
status: '资产状态',
|
||||
metric: '运行方式',
|
||||
updatedAt: '最近更新'
|
||||
},
|
||||
badgeTone: 'violet'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +234,24 @@ export const DETAIL_TITLES = {
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '服务状态',
|
||||
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
|
||||
},
|
||||
digitalWorkers: {
|
||||
configTitle: '技能档案',
|
||||
configDesc: '展示数字员工技能的编号、归口、执行计划和启停状态。',
|
||||
detailTitle: '技能内容',
|
||||
detailDesc: '展示当前版本记录的任务类型、调度范围和执行参数。',
|
||||
outputTitle: '执行安排',
|
||||
outputDesc: '展示什么时候执行、是否启动,以及当前运行方式。',
|
||||
ruleListTitle: '技能参数',
|
||||
checkListTitle: '启动状态',
|
||||
triggerTitle: '执行计划',
|
||||
triggerDesc: '当前技能的计划执行时间或触发方式。',
|
||||
toolTitle: '运行归口',
|
||||
toolDesc: '数字员工技能由后台调度执行,运行结果进入对应日志或业务资产。',
|
||||
historyTitle: '版本记录',
|
||||
historyDesc: '最近的技能配置快照。',
|
||||
publishTitle: '启动状态',
|
||||
publishDesc: '数字员工技能由资产状态和调度配置共同决定是否启动。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ import {
|
||||
resolveRiskRuleSeverity,
|
||||
resolveRiskRuleSeverityLabel
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
import {
|
||||
buildDigitalEmployeeContentRows,
|
||||
buildDigitalEmployeeDetailMeta,
|
||||
buildDigitalEmployeeListMeta,
|
||||
isDigitalEmployeeAsset,
|
||||
sanitizeDigitalEmployeeText
|
||||
} from './auditViewDigitalEmployeeModel.js'
|
||||
|
||||
const EXPENSE_TYPE_SCENARIO_LABELS = {
|
||||
travel: '差旅费',
|
||||
@@ -335,6 +342,9 @@ export function resolveTabId(source, typeKey) {
|
||||
if (typeKey === 'rules') {
|
||||
return resolveRuleTabId(source)
|
||||
}
|
||||
if (typeKey === 'digitalWorkers') {
|
||||
return isDigitalEmployeeAsset(source) ? 'digitalWorkers' : ''
|
||||
}
|
||||
return typeKey
|
||||
}
|
||||
|
||||
@@ -895,6 +905,9 @@ export function resolveTypeKey(assetType) {
|
||||
if (assetType === 'mcp') {
|
||||
return 'mcp'
|
||||
}
|
||||
if (assetType === 'task') {
|
||||
return 'digitalWorkers'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -958,6 +971,9 @@ export function buildRowRuntime(asset, typeKey) {
|
||||
if (typeKey === 'mcp') {
|
||||
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
|
||||
}
|
||||
if (typeKey === 'digitalWorkers') {
|
||||
return buildDigitalEmployeeListMeta(asset).executionMode
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -971,6 +987,9 @@ export function buildRowMetric(asset, typeKey) {
|
||||
if (typeKey === 'mcp') {
|
||||
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
|
||||
}
|
||||
if (typeKey === 'digitalWorkers') {
|
||||
return buildDigitalEmployeeListMeta(asset).executionMode
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1042,6 +1061,19 @@ export function buildListItem(asset) {
|
||||
? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json)
|
||||
: resolveRiskRuleSeverityLabel(asset.config_json)
|
||||
: ''
|
||||
const digitalMeta = typeKey === 'digitalWorkers' ? buildDigitalEmployeeListMeta(asset) : null
|
||||
const displayName = digitalMeta?.name || asset.name
|
||||
const displayCode = digitalMeta?.code || asset.code
|
||||
const displaySummary = digitalMeta?.summary || listSubtitle
|
||||
const displayOwner = digitalMeta?.owner || (isRiskRule ? creator : asset.owner)
|
||||
const displayReviewer = digitalMeta?.reviewer || reviewer
|
||||
const displayCategory = digitalMeta?.category || resolveDomainLabel(asset.domain)
|
||||
const displayScope =
|
||||
digitalMeta?.scope ||
|
||||
(typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(asset.scenario_json))
|
||||
const displayEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue
|
||||
const displayEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '鏄? : '鍚?)
|
||||
const displayEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled')
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
@@ -1052,15 +1084,17 @@ export function buildListItem(asset) {
|
||||
usesJsonRiskRule,
|
||||
ruleDocument,
|
||||
typeLabel: tabMeta.typeLabel,
|
||||
short: makeShort(asset.name),
|
||||
name: asset.name,
|
||||
code: asset.code,
|
||||
summary: listSubtitle,
|
||||
listSubtitle,
|
||||
category: resolveDomainLabel(asset.domain),
|
||||
owner: isRiskRule ? creator : asset.owner,
|
||||
reviewer,
|
||||
short: makeShort(displayName),
|
||||
name: displayName,
|
||||
code: displayCode,
|
||||
rawCode: asset.code,
|
||||
summary: displaySummary,
|
||||
listSubtitle: displaySummary,
|
||||
category: displayCategory,
|
||||
owner: displayOwner,
|
||||
reviewer: displayReviewer,
|
||||
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
|
||||
scope: displayScope,
|
||||
riskCategory: ruleScenarioCategory,
|
||||
scenarioList: ruleScenarioList,
|
||||
businessStageValue: businessStage.value,
|
||||
@@ -1086,6 +1120,9 @@ export function buildListItem(asset) {
|
||||
isEnabledValue,
|
||||
isEnabledLabel: isEnabledValue ? '是' : '否',
|
||||
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
||||
isEnabledValue: displayEnabledValue,
|
||||
isEnabledLabel: displayEnabledLabel,
|
||||
isEnabledTone: displayEnabledTone,
|
||||
modifiedBy,
|
||||
changeCount,
|
||||
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
|
||||
@@ -1417,6 +1454,25 @@ export function buildDetailViewModel(detail, runs) {
|
||||
const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson)
|
||||
const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson)
|
||||
const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson)
|
||||
const digitalMeta = typeKey === 'digitalWorkers'
|
||||
? buildDigitalEmployeeDetailMeta({
|
||||
...detail,
|
||||
updated_at: formatDateTime(detail.updated_at)
|
||||
})
|
||||
: null
|
||||
const detailName = digitalMeta?.name || detail.name
|
||||
const detailCode = digitalMeta?.code || detail.code
|
||||
const detailSummary = digitalMeta?.description ||
|
||||
(usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description)
|
||||
const detailOwner = digitalMeta?.owner || detail.owner
|
||||
const detailReviewer = digitalMeta?.reviewer || detail.reviewer || detail.latest_review?.reviewer || '寰呭垎閰?
|
||||
const detailCategory = digitalMeta?.category || resolveDomainLabel(detail.domain)
|
||||
const detailScope =
|
||||
digitalMeta?.scope ||
|
||||
(typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(detail.scenario_json))
|
||||
const detailEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue
|
||||
const detailEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '鏄? : '鍚?)
|
||||
const detailEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled')
|
||||
|
||||
return {
|
||||
id: detail.id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
canApproveBudgetExpenseApplications,
|
||||
canApproveLeaderExpenseClaims,
|
||||
canAccessAppView,
|
||||
canDeleteArchivedExpenseClaims,
|
||||
@@ -22,6 +23,24 @@ test('direct approvers can return claims without receiving delete permissions',
|
||||
assert.equal(canReturnExpenseClaims(approverUser), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims(managerUser), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims(approverUser), true)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P6' }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P8' }), true)
|
||||
assert.equal(
|
||||
canApproveBudgetExpenseApplications(
|
||||
{ roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '交付部' },
|
||||
{ departmentName: '交付部' }
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canApproveBudgetExpenseApplications(
|
||||
{ roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '财务部' },
|
||||
{ departmentName: '交付部' }
|
||||
),
|
||||
false
|
||||
)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: [], grade: 'P8' }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canManageExpenseClaims(managerUser), false)
|
||||
assert.equal(canManageExpenseClaims(approverUser), false)
|
||||
})
|
||||
@@ -81,6 +100,37 @@ test('finance approval inbox only processes finance-stage requests', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('budget approval inbox only processes budget-stage requests for budget monitor or senior finance roles', () => {
|
||||
const budgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '赵预算', departmentName: '交付部' }
|
||||
const otherDepartmentBudgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '王预算', departmentName: '财务部' }
|
||||
const seniorFinanceUser = { roleCodes: ['executive'], grade: 'P7', name: '高级财务' }
|
||||
const p8WithoutBudgetRole = { roleCodes: ['manager'], grade: 'P8', name: '高职级经理' }
|
||||
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, budgetUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, seniorFinanceUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest(
|
||||
{ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' },
|
||||
otherDepartmentBudgetUser
|
||||
),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三' }, p8WithoutBudgetRole),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, budgetUser),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('users with both finance and manager roles can process both relevant stages', () => {
|
||||
const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' }
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ test('detail topbar ignores system allowance rows when checking missing tickets'
|
||||
|
||||
assert.equal(hasMissingAttachment(request), false)
|
||||
assert.equal(hasPendingInfo(request), false)
|
||||
assert.deepEqual(alerts, ['直属领导审批'])
|
||||
assert.deepEqual(alerts, ['SLA 催单次数 0'])
|
||||
})
|
||||
|
||||
test('detail topbar still flags real manual rows without required ticket info', () => {
|
||||
@@ -96,7 +96,7 @@ test('detail topbar still flags real manual rows without required ticket info',
|
||||
|
||||
assert.equal(hasMissingAttachment(request), true)
|
||||
assert.equal(hasPendingInfo(request), true)
|
||||
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
|
||||
assert.deepEqual(alerts, ['SLA 催单次数 0', '缺少票据', '待补信息'])
|
||||
})
|
||||
|
||||
test('application detail topbar does not ask for receipt attachments', () => {
|
||||
@@ -122,5 +122,29 @@ test('application detail topbar does not ask for receipt attachments', () => {
|
||||
|
||||
assert.equal(hasMissingAttachment(request), false)
|
||||
assert.equal(alerts.includes('缺少票据'), false)
|
||||
assert.deepEqual(alerts, ['直属领导审批'])
|
||||
assert.deepEqual(alerts, ['SLA 催单次数 0'])
|
||||
})
|
||||
|
||||
test('detail topbar shows SLA reminder count from direct fields and reminder events', () => {
|
||||
const directAlerts = buildDetailAlerts({
|
||||
node: '直属领导审批',
|
||||
approvalKey: 'in_progress',
|
||||
slaReminderCount: 2,
|
||||
expenseItems: []
|
||||
})
|
||||
|
||||
const eventAlerts = buildDetailAlerts({
|
||||
node: '直属领导审批',
|
||||
approvalKey: 'in_progress',
|
||||
riskFlags: [
|
||||
{ source: 'sla_reminder', message: '下属已催单' },
|
||||
{ event_type: 'urge', message: '再次催单' }
|
||||
],
|
||||
expenseItems: []
|
||||
})
|
||||
|
||||
assert.equal(directAlerts[0].label, 'SLA 催单次数 2')
|
||||
assert.equal(directAlerts[0].tone, 'warning')
|
||||
assert.equal(directAlerts[0].icon, 'mdi mdi-bell-ring-outline')
|
||||
assert.equal(eventAlerts[0].label, 'SLA 催单次数 2')
|
||||
})
|
||||
|
||||
@@ -85,15 +85,20 @@ test('documents center list shows created time and conditional stay time columns
|
||||
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
|
||||
assert.match(documentsCenterView, /<col class="col-created">/)
|
||||
assert.match(documentsCenterView, /<col v-if="showStayTimeColumn" class="col-stay">/)
|
||||
assert.match(documentsCenterView, /<col class="col-initiator">/)
|
||||
assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/)
|
||||
assert.match(documentsCenterView, /<th>费用场景<\/th>[\s\S]*<th>发起人<\/th>[\s\S]*<th>事项<\/th>/)
|
||||
assert.match(documentsCenterView, /<td>\{\{ row\.createdAtDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td>\{\{ row\.initiatorName \}\}<\/td>/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
|
||||
)
|
||||
assert.match(documentsCenterView, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
|
||||
assert.match(documentsCenterView, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
|
||||
assert.match(documentsCenterView, /initiatorName,/)
|
||||
assert.match(documentsCenterView, /row\.initiatorName/)
|
||||
})
|
||||
|
||||
test('documents center action buttons are scoped to application and reimbursement tabs', () => {
|
||||
@@ -225,9 +230,10 @@ test('documents center status dropdown uses compact filter styling', () => {
|
||||
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
|
||||
assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||
assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/)
|
||||
assert.match(documentsCenterStyles, /min-width:\s*1320px;/)
|
||||
assert.match(documentsCenterStyles, /min-width:\s*1420px;/)
|
||||
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
|
||||
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
|
||||
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)
|
||||
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/)
|
||||
assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/)
|
||||
|
||||
@@ -5,10 +5,12 @@ import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||
|
||||
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
||||
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
||||
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
|
||||
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
|
||||
const RETURNED = '\u9000\u56de'
|
||||
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
|
||||
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
|
||||
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
|
||||
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
|
||||
|
||||
test('application claims are mapped as application documents', () => {
|
||||
@@ -41,7 +43,7 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||
@@ -50,6 +52,47 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
|
||||
})
|
||||
|
||||
test('application claims wait for department P8 budget monitor after leader approval', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-budget',
|
||||
claim_no: 'AP-20260525103145-BUDGET',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: '支撑国网服务器上线部署',
|
||||
location: '上海',
|
||||
amount: 12000,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T03:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: BUDGET_MANAGER_APPROVAL,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'Leader Li',
|
||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
next_approval_stage: BUDGET_MANAGER_APPROVAL,
|
||||
next_approver_name: '赵预算',
|
||||
next_approver_grade: 'P8',
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
})
|
||||
|
||||
test('returned application claims include leader return node and supplement status', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-returned',
|
||||
@@ -86,7 +129,7 @@ test('returned application claims include leader return node and supplement stat
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, RETURNED, WAIT_SUBMIT]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, RETURNED, WAIT_SUBMIT]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === RETURNED)?.time, 'Leader Li\u9000\u56de')
|
||||
assert.match(request.progressSteps.find((step) => step.label === RETURNED)?.detail, /2026-05-25/)
|
||||
@@ -96,7 +139,7 @@ test('returned application claims include leader return node and supplement stat
|
||||
assert.equal(request.progressSteps.some((step) => step.label === APPROVAL_COMPLETED), false)
|
||||
})
|
||||
|
||||
test('approved application claims complete after direct manager approval only', () => {
|
||||
test('approved application claims complete after budget approval', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-approved',
|
||||
claim_no: 'AP-20260525113045-HGFEDCBA',
|
||||
@@ -120,6 +163,16 @@ test('approved application claims complete after direct manager approval only',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: '李经理',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '预算管理者审批',
|
||||
next_approver_name: '赵预算',
|
||||
next_approver_grade: 'P8',
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
},
|
||||
{
|
||||
source: 'budget_approval',
|
||||
event_type: 'expense_application_budget_approval',
|
||||
operator: '赵预算',
|
||||
previous_approval_stage: '预算管理者审批',
|
||||
next_approval_stage: '审批完成',
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
}
|
||||
@@ -131,10 +184,11 @@ test('approved application claims complete after direct manager approval only',
|
||||
assert.equal(request.workflowNode, '审批完成')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
['创建申请', '直属领导审批', '审批完成']
|
||||
['创建申请', '直属领导审批', '预算管理者审批', '审批完成']
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过')
|
||||
})
|
||||
|
||||
test('progress steps show approval operator time and current stay duration', () => {
|
||||
|
||||
@@ -19,6 +19,10 @@ const approvalDialog = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const budgetAnalysisComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -53,18 +57,23 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailScript, /const canApproveRequest = computed/)
|
||||
assert.match(detailScript, /canApproveLeaderExpenseClaims/)
|
||||
assert.match(detailScript, /canApproveBudgetExpenseApplications/)
|
||||
assert.match(detailScript, /isCurrentDirectManagerForRequest/)
|
||||
assert.match(detailScript, /isCurrentRequestApplicant/)
|
||||
assert.match(detailScript, /isFinanceApprovalStage/)
|
||||
assert.match(detailScript, /const isBudgetApprovalStage = computed/)
|
||||
assert.match(detailScript, /const showBudgetAnalysis = computed/)
|
||||
assert.match(detailScript, /const isCurrentApplicant = computed/)
|
||||
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
|
||||
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
|
||||
assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/)
|
||||
assert.match(detailScript, /approvalOpinionTitle/)
|
||||
assert.match(detailScript, /approvalConfirmDescription/)
|
||||
assert.match(detailScript, /approvalNextStage/)
|
||||
assert.doesNotMatch(detailScript, /approvalNextStage/)
|
||||
assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/)
|
||||
assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/)
|
||||
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
|
||||
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => false\)/)
|
||||
assert.match(detailScript, /approvalOpinionTitle = computed\(\(\) => \(isFinanceApprovalStage\.value \? '财务意见' : '附加意见'\)\)/)
|
||||
assert.match(detailScript, /buildLeaderApprovalEvents/)
|
||||
assert.match(detailScript, /buildLeaderApprovalInfo/)
|
||||
assert.match(detailScript, /const leaderApprovalEvents = computed/)
|
||||
@@ -76,11 +85,13 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/)
|
||||
assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/)
|
||||
assert.match(detailScript, /canProcessFinanceApprovalStage\.value/)
|
||||
assert.match(detailScript, /canProcessBudgetApprovalStage\.value/)
|
||||
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
|
||||
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
|
||||
assert.match(detailScript, /approveActionLabel/)
|
||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
|
||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/)
|
||||
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
|
||||
assert.match(detailScript, /流转至预算管理者审批/)
|
||||
|
||||
assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
||||
assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/)
|
||||
@@ -96,6 +107,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.doesNotMatch(detailTemplate, /leaderApprovalReadonlyText/)
|
||||
assert.doesNotMatch(detailTemplate, /\u5f85\u76f4\u5c5e\u9886\u5bfc\u586b\u5199\u5ba1\u6279\u610f\u89c1/)
|
||||
assert.match(detailTemplate, /领导意见/)
|
||||
assert.match(detailTemplate, /<TravelRequestBudgetAnalysis[\s\S]*v-if="showBudgetAnalysis"[\s\S]*:claim-id="request\.claimId"/)
|
||||
assert.match(approvalDialog, /\{\{ opinionTitle \}\}/)
|
||||
assert.doesNotMatch(detailTemplate, /v-model="leaderOpinion"/)
|
||||
assert.match(detailTemplate, /@click="handleApproveRequest"/)
|
||||
@@ -105,7 +117,10 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
|
||||
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
|
||||
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
|
||||
assert.match(detailTemplate, /:next-stage="approvalNextStage"/)
|
||||
assert.doesNotMatch(detailTemplate, /:next-stage="approvalNextStage"/)
|
||||
assert.doesNotMatch(approvalDialog, /submit-confirm-summary/)
|
||||
assert.doesNotMatch(approvalDialog, /单据编号/)
|
||||
assert.doesNotMatch(approvalDialog, /当前节点/)
|
||||
assert.match(detailTemplate, /v-model:opinion="leaderOpinion"/)
|
||||
assert.match(detailTemplate, /:opinion-placeholder="approvalOpinionPlaceholder"/)
|
||||
assert.match(detailTemplate, /:opinion-hint="approvalOpinionHint"/)
|
||||
@@ -119,8 +134,8 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
|
||||
assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/)
|
||||
assert.match(confirmApproveRequest, /approveExpenseClaim/)
|
||||
assert.match(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
|
||||
assert.match(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
|
||||
assert.doesNotMatch(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
|
||||
assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
|
||||
|
||||
assert.match(approvalDialog, /<textarea/)
|
||||
assert.match(approvalDialog, /update:opinion/)
|
||||
@@ -141,4 +156,11 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
|
||||
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
|
||||
assert.match(reimbursementService, /\/approve/)
|
||||
assert.match(reimbursementService, /export function fetchExpenseClaimBudgetAnalysis/)
|
||||
assert.match(reimbursementService, /\/budget-analysis/)
|
||||
assert.match(budgetAnalysisComponent, /预算分析/)
|
||||
assert.match(budgetAnalysisComponent, /当前预算额度/)
|
||||
assert.match(budgetAnalysisComponent, /此次费用占预算/)
|
||||
assert.match(budgetAnalysisComponent, /综合评分/)
|
||||
assert.match(budgetAnalysisComponent, /fetchExpenseClaimBudgetAnalysis/)
|
||||
})
|
||||
|
||||
@@ -684,9 +684,12 @@ test('return reason dialog is wired into approval and detail return actions', ()
|
||||
assert.match(returnReasonDialog, /application_budget_basis_missing/)
|
||||
assert.match(returnReasonDialog, /application_policy_mismatch/)
|
||||
assert.match(returnReasonDialog, /application_attachment_needed/)
|
||||
assert.match(returnReasonDialog, /application_other/)
|
||||
assert.match(returnReasonDialog, /退单选项/)
|
||||
assert.match(returnReasonDialog, /selectionError/)
|
||||
assert.match(returnReasonDialog, /selectedCodes\.value\.length === 0/)
|
||||
assert.match(returnReasonDialog, /selectedApplicationCode/)
|
||||
assert.match(returnReasonDialog, /application \? 'radio' : 'checkbox'/)
|
||||
assert.match(returnReasonDialog, /selectedReasonCodes\.value\.length === 0/)
|
||||
assert.match(returnReasonDialog, /lastAutoReason/)
|
||||
assert.match(returnReasonDialog, /reason_codes/)
|
||||
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
|
||||
|
||||
Reference in New Issue
Block a user