feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
@@ -171,6 +171,7 @@
|
||||
<col v-if="showStayTimeColumn" class="col-stay">
|
||||
<col class="col-doc-type">
|
||||
<col class="col-scene">
|
||||
<col class="col-initiator">
|
||||
<col class="col-title">
|
||||
<col class="col-amount">
|
||||
<col class="col-node">
|
||||
@@ -184,6 +185,7 @@
|
||||
<th v-if="showStayTimeColumn">停留时间</th>
|
||||
<th>单据类型</th>
|
||||
<th>费用场景</th>
|
||||
<th>发起人</th>
|
||||
<th>事项</th>
|
||||
<th>金额</th>
|
||||
<th>当前环节</th>
|
||||
@@ -201,6 +203,7 @@
|
||||
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
|
||||
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
|
||||
<td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
|
||||
<td>{{ row.initiatorName }}</td>
|
||||
<td>{{ row.reason }}</td>
|
||||
<td>{{ row.amountDisplay }}</td>
|
||||
<td>{{ row.node }}</td>
|
||||
@@ -437,6 +440,7 @@ const filteredRows = computed(() => {
|
||||
row.documentNo,
|
||||
row.documentTypeLabel,
|
||||
row.typeLabel,
|
||||
row.initiatorName,
|
||||
row.reason,
|
||||
row.node,
|
||||
row.statusLabel
|
||||
@@ -538,6 +542,16 @@ function buildDocumentRow(request, options = {}) {
|
||||
const documentTypeLabel =
|
||||
normalized.documentTypeLabel
|
||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||
const initiatorName = String(
|
||||
normalized.person
|
||||
|| normalized.employeeName
|
||||
|| normalized.profileName
|
||||
|| normalized.applicant
|
||||
|| request?.employee_name
|
||||
|| request?.employeeName
|
||||
|| request?.person
|
||||
|| ''
|
||||
).trim() || '待补充'
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
@@ -547,6 +561,7 @@ function buildDocumentRow(request, options = {}) {
|
||||
documentTypeLabel,
|
||||
claimId,
|
||||
documentNo,
|
||||
initiatorName,
|
||||
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
statusGroup,
|
||||
statusLabel,
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
v-model="systemLevelFilter"
|
||||
:options="systemLevelFilterOptions"
|
||||
placeholder="全部"
|
||||
size="small"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -41,7 +40,6 @@
|
||||
v-model="systemEventTypeFilter"
|
||||
:options="systemEventTypeFilterOptions"
|
||||
placeholder="全部"
|
||||
size="small"
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -8,10 +8,21 @@
|
||||
<h2>文档库 / 文件夹</h2>
|
||||
<p>默认展示文件列表,点击具体文件后以弹窗方式展开预览。</p>
|
||||
</div>
|
||||
<label class="file-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
|
||||
</label>
|
||||
<div class="panel-tools">
|
||||
<label class="file-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
|
||||
</label>
|
||||
<button
|
||||
class="knowledge-sync-btn"
|
||||
type="button"
|
||||
:disabled="!canTriggerKnowledgeSync"
|
||||
@click="handleKnowledgeSync"
|
||||
>
|
||||
<i :class="syncingFolder ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-book-sync-outline'"></i>
|
||||
<span>{{ knowledgeSyncButtonLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="library-body">
|
||||
@@ -30,19 +41,7 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="folder-sync-block">
|
||||
<button
|
||||
class="new-folder-btn fixed knowledge-sync-btn"
|
||||
type="button"
|
||||
:disabled="!canTriggerKnowledgeSync"
|
||||
@click="handleKnowledgeSync"
|
||||
>
|
||||
<i :class="syncingFolder ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-book-sync-outline'"></i>
|
||||
<span>{{ knowledgeSyncButtonLabel }}</span>
|
||||
</button>
|
||||
<p class="folder-sync-meta">{{ knowledgeSyncHint }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
|
||||
<section class="document-area" :class="{ 'read-only': !isAdmin }">
|
||||
<div
|
||||
|
||||
@@ -177,6 +177,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TravelRequestBudgetAnalysis
|
||||
v-if="showBudgetAnalysis"
|
||||
:claim-id="request.claimId"
|
||||
/>
|
||||
|
||||
<div v-if="showApplicationLeaderOpinion" class="application-leader-opinion">
|
||||
<div class="application-leader-opinion-head">
|
||||
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
|
||||
@@ -760,10 +765,6 @@
|
||||
:confirm-text="approveConfirmText"
|
||||
:busy-text="approveBusyText"
|
||||
:busy="approveBusy"
|
||||
:document-no="request.documentNo || request.id"
|
||||
:node="request.node"
|
||||
:summary-label="approvalConfirmSummaryLabel"
|
||||
:next-stage="approvalNextStage"
|
||||
:opinion-title="approvalOpinionTitle"
|
||||
v-model:opinion="leaderOpinion"
|
||||
:opinion-placeholder="approvalOpinionPlaceholder"
|
||||
|
||||
@@ -372,7 +372,7 @@ function matchKeyword(employee, keyword) {
|
||||
return true
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
const fields = [
|
||||
employee.name,
|
||||
employee.employeeNo,
|
||||
employee.department,
|
||||
@@ -380,9 +380,13 @@ function matchKeyword(employee, keyword) {
|
||||
employee.email,
|
||||
employee.manager,
|
||||
employee.financeOwner,
|
||||
employee.syncState,
|
||||
...(employee.roles || [])
|
||||
employee.syncState
|
||||
]
|
||||
|
||||
const roles = Array.isArray(employee.roles) ? employee.roles : []
|
||||
|
||||
const haystack = [...fields, ...roles]
|
||||
.map((val) => String(val || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
@@ -167,31 +167,15 @@ export default {
|
||||
}
|
||||
return stats
|
||||
})
|
||||
const knowledgeSyncButtonLabel = computed(() => {
|
||||
if (syncingFolder.value || activeFolderIngestStats.value.syncing > 0) {
|
||||
return '归纳中...'
|
||||
}
|
||||
return '知识归纳'
|
||||
})
|
||||
const knowledgeSyncHint = computed(() => {
|
||||
const stats = activeFolderIngestStats.value
|
||||
if (!activeFolder.value) {
|
||||
return '请选择一个固定知识目录后再触发归纳。'
|
||||
}
|
||||
if (!stats.total) {
|
||||
return '当前目录暂无文档,上传后即可进行知识归纳。'
|
||||
}
|
||||
if (stats.syncing > 0) {
|
||||
return `当前目录有 ${stats.syncing} 份文档正在归纳,完成后会自动刷新状态。`
|
||||
}
|
||||
if (stats.pending > 0 || stats.failed > 0) {
|
||||
return `当前目录待归纳 ${stats.pending} 份,需重试 ${stats.failed} 份。`
|
||||
}
|
||||
return `当前目录 ${stats.ingested} 份文档已归纳,可手动触发一次增量检查。`
|
||||
})
|
||||
const canTriggerKnowledgeSync = computed(
|
||||
() =>
|
||||
isAdmin.value
|
||||
const knowledgeSyncButtonLabel = computed(() => {
|
||||
if (syncingFolder.value || activeFolderIngestStats.value.syncing > 0) {
|
||||
return '归纳中...'
|
||||
}
|
||||
return '知识归纳'
|
||||
})
|
||||
const canTriggerKnowledgeSync = computed(
|
||||
() =>
|
||||
isAdmin.value
|
||||
&& Boolean(activeFolder.value)
|
||||
&& activeFolderIngestStats.value.total > 0
|
||||
&& !syncingFolder.value
|
||||
@@ -445,11 +429,11 @@ export default {
|
||||
|
||||
syncingFolder.value = true
|
||||
try {
|
||||
const payload = await syncKnowledgeLibrary({
|
||||
folder: activeFolder.value,
|
||||
documentIds: [],
|
||||
force: false
|
||||
})
|
||||
const payload = await syncKnowledgeLibrary({
|
||||
folder: activeFolder.value,
|
||||
documentIds: [],
|
||||
force: true
|
||||
})
|
||||
|
||||
const queuedIds = Array.isArray(payload?.document_ids) ? payload.document_ids : []
|
||||
for (const documentId of queuedIds) {
|
||||
@@ -461,8 +445,9 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
toast(payload?.summary || '\u77e5\u8bc6\u5f52\u7eb3\u4efb\u52a1\u5df2\u63d0\u4ea4\u3002')
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
const runHint = payload?.agent_run_id ? `日志编号:${payload.agent_run_id}` : ''
|
||||
toast([payload?.summary || '知识归纳任务已提交。', runHint].filter(Boolean).join(' '))
|
||||
} catch (error) {
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
toast(error.message || '\u77e5\u8bc6\u5f52\u7eb3\u89e6\u53d1\u5931\u8d25\u3002')
|
||||
@@ -647,9 +632,8 @@ export default {
|
||||
handleFileInput,
|
||||
handleKnowledgeSync,
|
||||
isAdmin,
|
||||
knowledgeSyncButtonLabel,
|
||||
knowledgeSyncHint,
|
||||
loading,
|
||||
knowledgeSyncButtonLabel,
|
||||
loading,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
pageSizes,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToast } from '../../composables/useToast.js'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
|
||||
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
||||
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
||||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
canApproveBudgetExpenseApplications,
|
||||
canApproveLeaderExpenseClaims,
|
||||
canDeleteArchivedExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
@@ -369,6 +371,7 @@ export default {
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect,
|
||||
TravelRequestApprovalDialog,
|
||||
TravelRequestBudgetAnalysis,
|
||||
TravelRequestDeleteDialog,
|
||||
TravelRequestReturnDialog
|
||||
},
|
||||
@@ -490,6 +493,10 @@ export default {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '财务审批'
|
||||
})
|
||||
const isBudgetApprovalStage = computed(() => {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '预算管理者审批'
|
||||
})
|
||||
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
|
||||
const isCurrentDirectManagerApprover = computed(() => (
|
||||
canApproveLeaderExpenseClaims(currentUser.value)
|
||||
@@ -501,6 +508,18 @@ export default {
|
||||
&& isFinanceUser(currentUser.value)
|
||||
&& !isCurrentApplicant.value
|
||||
))
|
||||
const canProcessBudgetApprovalStage = computed(() => (
|
||||
isApplicationDocument.value
|
||||
&& isBudgetApprovalStage.value
|
||||
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
||||
&& !isCurrentApplicant.value
|
||||
))
|
||||
const showBudgetAnalysis = computed(() => (
|
||||
isApplicationDocument.value
|
||||
&& isBudgetApprovalStage.value
|
||||
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
||||
&& !isCurrentApplicant.value
|
||||
))
|
||||
const canReturnRequest = computed(() => {
|
||||
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
|
||||
return false
|
||||
@@ -508,6 +527,9 @@ export default {
|
||||
if (isDirectManagerApprovalStage.value) {
|
||||
return isCurrentDirectManagerApprover.value
|
||||
}
|
||||
if (isBudgetApprovalStage.value) {
|
||||
return canProcessBudgetApprovalStage.value
|
||||
}
|
||||
return canProcessFinanceApprovalStage.value
|
||||
})
|
||||
const canApproveRequest = computed(() =>
|
||||
@@ -520,6 +542,7 @@ export default {
|
||||
&& isCurrentDirectManagerApprover.value
|
||||
)
|
||||
|| canProcessFinanceApprovalStage.value
|
||||
|| canProcessBudgetApprovalStage.value
|
||||
)
|
||||
)
|
||||
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
||||
@@ -536,39 +559,43 @@ export default {
|
||||
isApplicationDocument.value
|
||||
&& hasLeaderApprovalEvents.value
|
||||
))
|
||||
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
|
||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||||
const requiresApprovalOpinion = computed(() => false)
|
||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见'))
|
||||
const approvalOpinionPlaceholder = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||||
}
|
||||
if (isApplicationDocument.value) {
|
||||
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。'
|
||||
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
|
||||
}
|
||||
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
||||
return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。'
|
||||
})
|
||||
const approvalOpinionHint = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '审核通过后将进入归档入账。'
|
||||
}
|
||||
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。'
|
||||
if (isBudgetApprovalStage.value) {
|
||||
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
|
||||
}
|
||||
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后会流转至预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
|
||||
})
|
||||
const approvalConfirmBadge = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '财务终审'
|
||||
}
|
||||
return isBudgetApprovalStage.value ? '预算审核' : '领导审批'
|
||||
})
|
||||
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
||||
const approvalConfirmDescription = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||||
}
|
||||
if (isApplicationDocument.value) {
|
||||
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。'
|
||||
return isBudgetApprovalStage.value
|
||||
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
|
||||
: '确认后该申请单会完成直属领导审批,并流转给预算管理者进一步审核。'
|
||||
}
|
||||
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||
})
|
||||
const approvalNextStage = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return '归档入账'
|
||||
}
|
||||
return isApplicationDocument.value ? '报销草稿' : '财务审批'
|
||||
})
|
||||
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
|
||||
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
|
||||
const approveConfirmTitle = computed(() => (
|
||||
@@ -581,15 +608,14 @@ export default {
|
||||
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
|
||||
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
|
||||
))
|
||||
const approvalConfirmSummaryLabel = computed(() => (
|
||||
isApplicationDocument.value ? '生成结果' : '下一节点'
|
||||
))
|
||||
const approvalSuccessToast = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
return `${request.value.id} 已完成财务终审,进入归档入账。`
|
||||
}
|
||||
return isApplicationDocument.value
|
||||
? `${request.value.id} 已确认审核,正在生成报销草稿。`
|
||||
? isBudgetApprovalStage.value
|
||||
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
|
||||
: `${request.value.id} 已确认审核,已流转至预算管理者审批。`
|
||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||
})
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
@@ -1751,15 +1777,10 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
||||
toast('请先填写领导意见,填写后才能确认审核。')
|
||||
return
|
||||
}
|
||||
|
||||
approveBusy.value = true
|
||||
try {
|
||||
const responsePayload = await approveExpenseClaim(request.value.claimId, {
|
||||
opinion: leaderOpinion.value.trim()
|
||||
opinion: leaderOpinion.value.trim() || '同意'
|
||||
})
|
||||
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
|
||||
approveConfirmDialogOpen.value = false
|
||||
@@ -1805,7 +1826,7 @@ export default {
|
||||
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint,
|
||||
approvalConfirmDescription, approvalOpinionHint,
|
||||
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
||||
applicationDetailFactItems,
|
||||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||||
@@ -1836,6 +1857,7 @@ export default {
|
||||
requiresApprovalOpinion,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||
showBudgetAnalysis,
|
||||
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
|
||||
263
web/src/views/scripts/auditViewDigitalEmployeeModel.js
Normal file
263
web/src/views/scripts/auditViewDigitalEmployeeModel.js
Normal file
@@ -0,0 +1,263 @@
|
||||
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
daily_risk_scan: '每日风险巡检',
|
||||
global_risk_scan: '全局风险巡检',
|
||||
weekly_ar_summary: '周度应收账龄汇总',
|
||||
weekly_expense_report: '周度费用洞察',
|
||||
rule_review_digest: '规则待审摘要',
|
||||
knowledge_index_sync: '知识库归集',
|
||||
x_financial_callback: '任务回调上报'
|
||||
}
|
||||
|
||||
const CONTENT_LABELS = {
|
||||
task_type: '技能类型',
|
||||
schedule: '执行计划',
|
||||
cron: '调度表达式',
|
||||
folder: '归集范围',
|
||||
changed_only: '仅处理变更',
|
||||
force: '强制重建',
|
||||
index_engine: '索引引擎',
|
||||
callback_type: '回调类型',
|
||||
status: '回写状态',
|
||||
summary: '结果摘要'
|
||||
}
|
||||
|
||||
const HIDDEN_CONTENT_KEYS = new Set([
|
||||
'agent',
|
||||
'target_agent',
|
||||
'callback_token',
|
||||
'token',
|
||||
'api_key',
|
||||
'authorization'
|
||||
])
|
||||
|
||||
export function normalizeDigitalEmployeeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function sanitizeDigitalEmployeeText(value, fallback = '') {
|
||||
const text = normalizeDigitalEmployeeText(value)
|
||||
.replace(/hermes/gi, '数字员工')
|
||||
.replace(/赫尔墨斯/g, '数字员工')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
export function sanitizeDigitalEmployeeName(value, fallback = '数字员工技能') {
|
||||
const text = sanitizeDigitalEmployeeText(value, fallback)
|
||||
.replace(/^数字员工[\s·::-]*/i, '')
|
||||
.trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
export function parseDigitalEmployeeContent(value) {
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeTaskType(source = {}, content = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
const raw =
|
||||
normalizeDigitalEmployeeText(content.task_type) ||
|
||||
normalizeDigitalEmployeeText(config.task_type) ||
|
||||
normalizeDigitalEmployeeText(source.task_type) ||
|
||||
normalizeDigitalEmployeeText(source.code).replace(/^task\.hermes\./i, '')
|
||||
return raw.replace(/[-.]/g, '_')
|
||||
}
|
||||
|
||||
export function isDigitalEmployeeAsset(source = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
const haystack = [
|
||||
source.asset_type,
|
||||
source.code,
|
||||
source.name,
|
||||
source.description,
|
||||
config.agent,
|
||||
config.target_agent,
|
||||
config.worker,
|
||||
config.runtime_agent
|
||||
]
|
||||
.map((item) => normalizeDigitalEmployeeText(item).toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
normalizeDigitalEmployeeText(source.asset_type) === 'task' &&
|
||||
(haystack.includes(DIGITAL_EMPLOYEE_AGENT) || haystack.includes('task.hermes.'))
|
||||
)
|
||||
}
|
||||
|
||||
export function formatDigitalEmployeeCron(value) {
|
||||
const raw = normalizeDigitalEmployeeText(value)
|
||||
if (!raw) {
|
||||
return '手动触发'
|
||||
}
|
||||
|
||||
const parts = raw.split(/\s+/)
|
||||
if (parts.length < 5) {
|
||||
return sanitizeDigitalEmployeeText(raw)
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
const hourNumber = Number(hour)
|
||||
const minuteNumber = Number(minute)
|
||||
const timeLabel =
|
||||
Number.isFinite(hourNumber) && Number.isFinite(minuteNumber)
|
||||
? `${String(hourNumber).padStart(2, '0')}:${String(minuteNumber).padStart(2, '0')}`
|
||||
: `${hour}:${minute}`
|
||||
|
||||
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return `每天 ${timeLabel}`
|
||||
}
|
||||
|
||||
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
|
||||
const weekdayLabels = {
|
||||
'0': '周日',
|
||||
'1': '周一',
|
||||
'2': '周二',
|
||||
'3': '周三',
|
||||
'4': '周四',
|
||||
'5': '周五',
|
||||
'6': '周六',
|
||||
'7': '周日'
|
||||
}
|
||||
return `每${weekdayLabels[dayOfWeek] || `周${dayOfWeek}`} ${timeLabel}`
|
||||
}
|
||||
|
||||
return sanitizeDigitalEmployeeText(raw)
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeSchedule(source = {}, content = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
const raw =
|
||||
normalizeDigitalEmployeeText(content.schedule) ||
|
||||
normalizeDigitalEmployeeText(config.cron) ||
|
||||
normalizeDigitalEmployeeText(config.schedule) ||
|
||||
normalizeDigitalEmployeeText(config.cron_expression)
|
||||
return {
|
||||
value: raw,
|
||||
label: formatDigitalEmployeeCron(raw)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeEnabled(source = {}) {
|
||||
const config = source.config_json || source.configJson || {}
|
||||
if (config.enabled === false || config.is_enabled === false) {
|
||||
return false
|
||||
}
|
||||
if (source.enabled === false || source.is_enabled === false) {
|
||||
return false
|
||||
}
|
||||
return normalizeDigitalEmployeeText(source.status || 'active') === 'active'
|
||||
}
|
||||
|
||||
export function resolveDigitalEmployeeDisplayCode(source = {}, content = {}) {
|
||||
const taskType = resolveDigitalEmployeeTaskType(source, content)
|
||||
return taskType ? `digital.${taskType}` : 'digital.skill'
|
||||
}
|
||||
|
||||
function formatDigitalEmployeeValue(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeDigitalEmployeeText(item)).filter(Boolean).join('、') || '-'
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return sanitizeDigitalEmployeeText(JSON.stringify(value, null, 2))
|
||||
}
|
||||
return sanitizeDigitalEmployeeText(value, '-')
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeContentRows(content = {}) {
|
||||
return Object.entries(content)
|
||||
.filter(([key]) => !HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase()))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: CONTENT_LABELS[key] || key,
|
||||
value: formatDigitalEmployeeValue(value)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeContentPreview(content = {}) {
|
||||
const visiblePayload = {}
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
if (HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
visiblePayload[key] = value
|
||||
}
|
||||
return sanitizeDigitalEmployeeText(JSON.stringify(visiblePayload, null, 2))
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeListMeta(source = {}) {
|
||||
const content = parseDigitalEmployeeContent(source.current_version_content)
|
||||
const taskType = resolveDigitalEmployeeTaskType(source, content)
|
||||
const schedule = resolveDigitalEmployeeSchedule(source, content)
|
||||
const enabled = resolveDigitalEmployeeEnabled(source)
|
||||
const fallbackName = TASK_TYPE_LABELS[taskType] || '数字员工技能'
|
||||
|
||||
return {
|
||||
name: sanitizeDigitalEmployeeName(source.name, fallbackName),
|
||||
code: resolveDigitalEmployeeDisplayCode(source, content),
|
||||
summary: sanitizeDigitalEmployeeText(source.description, '面向后台自动执行的数字员工技能。'),
|
||||
category: '数字员工',
|
||||
owner: sanitizeDigitalEmployeeText(source.owner, '平台运营'),
|
||||
reviewer: sanitizeDigitalEmployeeText(source.reviewer, '系统'),
|
||||
scope: schedule.label,
|
||||
scheduleLabel: schedule.label,
|
||||
executionMode: schedule.value ? '定时执行' : '手动触发',
|
||||
enabled,
|
||||
enabledLabel: enabled ? '已启动' : '未启动',
|
||||
enabledTone: enabled ? 'success' : 'disabled',
|
||||
taskType
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeDetailMeta(source = {}) {
|
||||
const content = parseDigitalEmployeeContent(source.current_version_content)
|
||||
const listMeta = buildDigitalEmployeeListMeta({
|
||||
...source,
|
||||
current_version_content: content
|
||||
})
|
||||
const schedule = resolveDigitalEmployeeSchedule(source, content)
|
||||
const contentRows = buildDigitalEmployeeContentRows(content)
|
||||
|
||||
return {
|
||||
...listMeta,
|
||||
rawCode: normalizeDigitalEmployeeText(source.code),
|
||||
description: sanitizeDigitalEmployeeText(
|
||||
source.description,
|
||||
'该技能由后台数字员工按计划执行,并把结果沉淀到对应业务资产或运行日志中。'
|
||||
),
|
||||
contentRows,
|
||||
contentPreview: buildDigitalEmployeeContentPreview(content),
|
||||
scheduleRows: [
|
||||
{ label: '执行计划', value: schedule.label },
|
||||
{ label: '调度表达式', value: schedule.value || '手动触发' },
|
||||
{ label: '启动状态', value: listMeta.enabledLabel, tone: listMeta.enabledTone },
|
||||
{ label: '执行方式', value: listMeta.executionMode }
|
||||
],
|
||||
overviewRows: [
|
||||
{ label: '能力编号', value: listMeta.code },
|
||||
{ label: '业务归口', value: listMeta.owner },
|
||||
{ label: '当前版本', value: source.working_version || source.current_version || '-' },
|
||||
{ label: '最近更新', value: source.updated_at || '-' }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,32 @@ export const TAB_META = {
|
||||
...TYPE_META.mcp,
|
||||
typeKey: 'mcp',
|
||||
badgeTone: 'amber'
|
||||
},
|
||||
digitalWorkers: {
|
||||
assetType: 'task',
|
||||
typeKey: 'digitalWorkers',
|
||||
label: '数字员工',
|
||||
typeLabel: '数字员工',
|
||||
createButtonLabel: '数字员工已接入',
|
||||
hintText: '归集后台自动执行的数字员工技能,可查看技能内容、执行计划、启动状态和最近版本。',
|
||||
searchPlaceholder: '搜索数字员工技能、编号、执行计划或维护人',
|
||||
showMetricColumn: true,
|
||||
showRuntimeColumn: true,
|
||||
showVersionColumn: true,
|
||||
showStatusColumn: true,
|
||||
showEnabledColumn: true,
|
||||
tableColumns: {
|
||||
name: '技能名称',
|
||||
category: '归集标签',
|
||||
owner: '维护归口',
|
||||
scope: '执行计划',
|
||||
runtime: '触发方式',
|
||||
version: '当前版本',
|
||||
status: '资产状态',
|
||||
metric: '运行方式',
|
||||
updatedAt: '最近更新'
|
||||
},
|
||||
badgeTone: 'violet'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +234,24 @@ export const DETAIL_TITLES = {
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '服务状态',
|
||||
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
|
||||
},
|
||||
digitalWorkers: {
|
||||
configTitle: '技能档案',
|
||||
configDesc: '展示数字员工技能的编号、归口、执行计划和启停状态。',
|
||||
detailTitle: '技能内容',
|
||||
detailDesc: '展示当前版本记录的任务类型、调度范围和执行参数。',
|
||||
outputTitle: '执行安排',
|
||||
outputDesc: '展示什么时候执行、是否启动,以及当前运行方式。',
|
||||
ruleListTitle: '技能参数',
|
||||
checkListTitle: '启动状态',
|
||||
triggerTitle: '执行计划',
|
||||
triggerDesc: '当前技能的计划执行时间或触发方式。',
|
||||
toolTitle: '运行归口',
|
||||
toolDesc: '数字员工技能由后台调度执行,运行结果进入对应日志或业务资产。',
|
||||
historyTitle: '版本记录',
|
||||
historyDesc: '最近的技能配置快照。',
|
||||
publishTitle: '启动状态',
|
||||
publishDesc: '数字员工技能由资产状态和调度配置共同决定是否启动。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ import {
|
||||
resolveRiskRuleSeverity,
|
||||
resolveRiskRuleSeverityLabel
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
import {
|
||||
buildDigitalEmployeeContentRows,
|
||||
buildDigitalEmployeeDetailMeta,
|
||||
buildDigitalEmployeeListMeta,
|
||||
isDigitalEmployeeAsset,
|
||||
sanitizeDigitalEmployeeText
|
||||
} from './auditViewDigitalEmployeeModel.js'
|
||||
|
||||
const EXPENSE_TYPE_SCENARIO_LABELS = {
|
||||
travel: '差旅费',
|
||||
@@ -335,6 +342,9 @@ export function resolveTabId(source, typeKey) {
|
||||
if (typeKey === 'rules') {
|
||||
return resolveRuleTabId(source)
|
||||
}
|
||||
if (typeKey === 'digitalWorkers') {
|
||||
return isDigitalEmployeeAsset(source) ? 'digitalWorkers' : ''
|
||||
}
|
||||
return typeKey
|
||||
}
|
||||
|
||||
@@ -895,6 +905,9 @@ export function resolveTypeKey(assetType) {
|
||||
if (assetType === 'mcp') {
|
||||
return 'mcp'
|
||||
}
|
||||
if (assetType === 'task') {
|
||||
return 'digitalWorkers'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -958,6 +971,9 @@ export function buildRowRuntime(asset, typeKey) {
|
||||
if (typeKey === 'mcp') {
|
||||
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
|
||||
}
|
||||
if (typeKey === 'digitalWorkers') {
|
||||
return buildDigitalEmployeeListMeta(asset).executionMode
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -971,6 +987,9 @@ export function buildRowMetric(asset, typeKey) {
|
||||
if (typeKey === 'mcp') {
|
||||
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
|
||||
}
|
||||
if (typeKey === 'digitalWorkers') {
|
||||
return buildDigitalEmployeeListMeta(asset).executionMode
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1042,6 +1061,19 @@ export function buildListItem(asset) {
|
||||
? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json)
|
||||
: resolveRiskRuleSeverityLabel(asset.config_json)
|
||||
: ''
|
||||
const digitalMeta = typeKey === 'digitalWorkers' ? buildDigitalEmployeeListMeta(asset) : null
|
||||
const displayName = digitalMeta?.name || asset.name
|
||||
const displayCode = digitalMeta?.code || asset.code
|
||||
const displaySummary = digitalMeta?.summary || listSubtitle
|
||||
const displayOwner = digitalMeta?.owner || (isRiskRule ? creator : asset.owner)
|
||||
const displayReviewer = digitalMeta?.reviewer || reviewer
|
||||
const displayCategory = digitalMeta?.category || resolveDomainLabel(asset.domain)
|
||||
const displayScope =
|
||||
digitalMeta?.scope ||
|
||||
(typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(asset.scenario_json))
|
||||
const displayEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue
|
||||
const displayEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '鏄? : '鍚?)
|
||||
const displayEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled')
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
@@ -1052,15 +1084,17 @@ export function buildListItem(asset) {
|
||||
usesJsonRiskRule,
|
||||
ruleDocument,
|
||||
typeLabel: tabMeta.typeLabel,
|
||||
short: makeShort(asset.name),
|
||||
name: asset.name,
|
||||
code: asset.code,
|
||||
summary: listSubtitle,
|
||||
listSubtitle,
|
||||
category: resolveDomainLabel(asset.domain),
|
||||
owner: isRiskRule ? creator : asset.owner,
|
||||
reviewer,
|
||||
short: makeShort(displayName),
|
||||
name: displayName,
|
||||
code: displayCode,
|
||||
rawCode: asset.code,
|
||||
summary: displaySummary,
|
||||
listSubtitle: displaySummary,
|
||||
category: displayCategory,
|
||||
owner: displayOwner,
|
||||
reviewer: displayReviewer,
|
||||
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
|
||||
scope: displayScope,
|
||||
riskCategory: ruleScenarioCategory,
|
||||
scenarioList: ruleScenarioList,
|
||||
businessStageValue: businessStage.value,
|
||||
@@ -1086,6 +1120,9 @@ export function buildListItem(asset) {
|
||||
isEnabledValue,
|
||||
isEnabledLabel: isEnabledValue ? '是' : '否',
|
||||
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
||||
isEnabledValue: displayEnabledValue,
|
||||
isEnabledLabel: displayEnabledLabel,
|
||||
isEnabledTone: displayEnabledTone,
|
||||
modifiedBy,
|
||||
changeCount,
|
||||
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
|
||||
@@ -1417,6 +1454,25 @@ export function buildDetailViewModel(detail, runs) {
|
||||
const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson)
|
||||
const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson)
|
||||
const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson)
|
||||
const digitalMeta = typeKey === 'digitalWorkers'
|
||||
? buildDigitalEmployeeDetailMeta({
|
||||
...detail,
|
||||
updated_at: formatDateTime(detail.updated_at)
|
||||
})
|
||||
: null
|
||||
const detailName = digitalMeta?.name || detail.name
|
||||
const detailCode = digitalMeta?.code || detail.code
|
||||
const detailSummary = digitalMeta?.description ||
|
||||
(usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description)
|
||||
const detailOwner = digitalMeta?.owner || detail.owner
|
||||
const detailReviewer = digitalMeta?.reviewer || detail.reviewer || detail.latest_review?.reviewer || '寰呭垎閰?
|
||||
const detailCategory = digitalMeta?.category || resolveDomainLabel(detail.domain)
|
||||
const detailScope =
|
||||
digitalMeta?.scope ||
|
||||
(typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(detail.scenario_json))
|
||||
const detailEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue
|
||||
const detailEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '鏄? : '鍚?)
|
||||
const detailEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled')
|
||||
|
||||
return {
|
||||
id: detail.id,
|
||||
|
||||
Reference in New Issue
Block a user