领导意见
@@ -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"
diff --git a/web/src/views/scripts/EmployeeManagementView.js b/web/src/views/scripts/EmployeeManagementView.js
index 98bb6d6..d119995 100644
--- a/web/src/views/scripts/EmployeeManagementView.js
+++ b/web/src/views/scripts/EmployeeManagementView.js
@@ -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()
diff --git a/web/src/views/scripts/PoliciesView.js b/web/src/views/scripts/PoliciesView.js
index 874c787..09540e0 100644
--- a/web/src/views/scripts/PoliciesView.js
+++ b/web/src/views/scripts/PoliciesView.js
@@ -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,
diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js
index d5f0a71..417e997 100644
--- a/web/src/views/scripts/TravelRequestDetailView.js
+++ b/web/src/views/scripts/TravelRequestDetailView.js
@@ -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
diff --git a/web/src/views/scripts/auditViewDigitalEmployeeModel.js b/web/src/views/scripts/auditViewDigitalEmployeeModel.js
new file mode 100644
index 0000000..3249fa5
--- /dev/null
+++ b/web/src/views/scripts/auditViewDigitalEmployeeModel.js
@@ -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 || '-' }
+ ]
+ }
+}
diff --git a/web/src/views/scripts/auditViewMetadata.js b/web/src/views/scripts/auditViewMetadata.js
index ea08d5f..d611114 100644
--- a/web/src/views/scripts/auditViewMetadata.js
+++ b/web/src/views/scripts/auditViewMetadata.js
@@ -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: '数字员工技能由资产状态和调度配置共同决定是否启动。'
}
}
diff --git a/web/src/views/scripts/auditViewModel.js b/web/src/views/scripts/auditViewModel.js
index 1e1f3fa..17463c8 100644
--- a/web/src/views/scripts/auditViewModel.js
+++ b/web/src/views/scripts/auditViewModel.js
@@ -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,
diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs
index 7769b0a..9479727 100644
--- a/web/tests/accessControl.test.mjs
+++ b/web/tests/accessControl.test.mjs
@@ -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: '李经理' }
diff --git a/web/tests/app-shell-detail-alerts.test.mjs b/web/tests/app-shell-detail-alerts.test.mjs
index 6b862a3..3153ee2 100644
--- a/web/tests/app-shell-detail-alerts.test.mjs
+++ b/web/tests/app-shell-detail-alerts.test.mjs
@@ -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')
})
diff --git a/web/tests/documents-center-status-filter.test.mjs b/web/tests/documents-center-status-filter.test.mjs
index 68619b5..4ff0ee7 100644
--- a/web/tests/documents-center-status-filter.test.mjs
+++ b/web/tests/documents-center-status-filter.test.mjs
@@ -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, /
/)
assert.match(documentsCenterView, //)
+ assert.match(documentsCenterView, //)
assert.match(documentsCenterView, /单号<\/th>[\s\S]* | 创建时间<\/th>[\s\S]* | 停留时间<\/th>/)
+ assert.match(documentsCenterView, / | 费用场景<\/th>[\s\S]* | 发起人<\/th>[\s\S]* | 事项<\/th>/)
assert.match(documentsCenterView, / | \{\{ row\.createdAtDisplay \}\}<\/td>/)
assert.match(documentsCenterView, / | \{\{ row\.stayTimeDisplay \}\}<\/td>/)
+ assert.match(documentsCenterView, / | \{\{ 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;/)
diff --git a/web/tests/requestProgressSteps.test.mjs b/web/tests/requestProgressSteps.test.mjs
index 62fe3f6..d2c91ab 100644
--- a/web/tests/requestProgressSteps.test.mjs
+++ b/web/tests/requestProgressSteps.test.mjs
@@ -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', () => {
diff --git a/web/tests/travel-request-detail-leader-approval.test.mjs b/web/tests/travel-request-detail-leader-approval.test.mjs
index aa0e502..10197e9 100644
--- a/web/tests/travel-request-detail-leader-approval.test.mjs
+++ b/web/tests/travel-request-detail-leader-approval.test.mjs
@@ -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, / |