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

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