feat: 新增预算费控模型与报销审批流引擎

后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 17:31:27 +08:00
parent cbb98f4469
commit d4d5d40569
75 changed files with 5393 additions and 686 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;
}

View 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);
}

View File

@@ -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%; }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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: '' },

View 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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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',

View File

@@ -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,

View File

@@ -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('领导审批')

View File

@@ -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' })

View File

@@ -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,

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View 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 || '-' }
]
}
}

View File

@@ -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: '数字员工技能由资产状态和调度配置共同决定是否启动。'
}
}

View File

@@ -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,

View File

@@ -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: '李经理' }

View File

@@ -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')
})

View File

@@ -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;/)

View File

@@ -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', () => {

View File

@@ -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/)
})

View File

@@ -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/)