feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -12,7 +12,18 @@
:loading-message="`正在加载${activeTabLabel}资产`"
loading-icon="mdi mdi-view-list-outline"
:hint="hintText"
:show-pagination="!loading && !errorMessage && visibleSkills.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleSkills.length"
:total-pages="totalPages"
@update:active-tab="emit('update:activeType', $event)"
@update:current-page="currentPage = $event"
@update:page-size="changePageSize"
@empty-action="emit('empty-action')"
>
<template #filters>
@@ -178,7 +189,7 @@
</thead>
<tbody>
<tr
v-for="skill in visibleSkills"
v-for="skill in pagedSkills"
:key="skill.id"
:class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }"
@click="emit('open-asset-detail', skill)"
@@ -221,17 +232,11 @@
</tbody>
</table>
</template>
<template #footer>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer>
</template>
</EnterpriseListPage>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
@@ -310,9 +315,66 @@ const shellTabs = computed(() =>
}))
)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizeOptions = [10, 20, 50].map((size) => ({
label: `${size} 条/页`,
value: size
}))
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleSkills.length / pageSize.value)))
const pagedSkills = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return props.visibleSkills.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
)
watch(
() => [
props.activeType,
props.keyword,
props.selectedDomain,
props.selectedOwner,
props.selectedRiskLevel,
props.selectedStatus,
props.selectedRiskScenario,
props.selectedOnlineState,
props.selectedEnabledState
],
() => {
currentPage.value = 1
}
)
watch(
() => props.visibleSkills.length,
() => {
currentPage.value = Math.min(currentPage.value, totalPages.value)
if (currentPage.value < 1) {
currentPage.value = 1
}
},
{ immediate: true }
)
function selectFilter(type, value) {
emit('select-filter', type, value)
}
function changePageSize(size) {
const nextSize = Number(size) || 10
pageSize.value = nextSize
currentPage.value = 1
}
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>

View File

@@ -16,6 +16,30 @@
@confirm="emit('submit-risk-rule-create')"
>
<div class="risk-rule-create-form">
<label class="span-2">
<span>常见规则模板</span>
<EnterpriseSelect
v-model="selectedRiskRuleTemplateId"
:options="riskRuleTemplateOptions"
:disabled="riskRuleCreateBusy || riskRuleTemplatesLoading"
:placeholder="riskRuleTemplatesLoading ? '模板加载中...' : '可选:从常见规则预填'"
clearable
filterable
@change="applyRiskRuleTemplate"
/>
</label>
<div v-if="selectedRiskRuleTemplate" class="risk-rule-template-preview span-2">
<div class="risk-rule-template-preview__head">
<strong>{{ selectedRiskRuleTemplate.title }}</strong>
<span>{{ selectedRiskRuleTemplate.group_label }}</span>
</div>
<p>{{ selectedRiskRuleTemplate.description }}</p>
<small>字段{{ formatTemplateFields(selectedRiskRuleTemplate.fields) }}</small>
<small>模板只预填规则文本提交后仍走通用自然语言生成链路</small>
</div>
<p v-else-if="riskRuleTemplateLoadFailed" class="risk-rule-template-note span-2">
常见模板暂时加载失败可以继续手动编写规则
</p>
<label>
<span>业务环节</span>
<EnterpriseSelect
@@ -72,9 +96,9 @@
badge="规则维护"
badge-tone="info"
:title="riskRuleEditMode === 'revision' ? '创建修订版本' : '编辑风险规则'"
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述。'"
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿并重新生成执行模板。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述,并同步重新生成执行模板。'"
cancel-text="取消"
:confirm-text="riskRuleEditMode === 'revision' ? '创建修订' : '保存草稿'"
:confirm-text="riskRuleEditMode === 'revision' ? '创建并生成' : '保存并生成'"
busy-text="保存中..."
confirm-tone="primary"
confirm-icon="mdi mdi-content-save-outline"
@@ -280,10 +304,11 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import RiskRuleTestDialog from '../shared/RiskRuleTestDialog.vue'
import { fetchRiskRuleTemplates } from '../../services/agentAssets.js'
defineOptions({
name: 'AuditRuleDialogs'
@@ -354,7 +379,123 @@ const reviewSubmitReviewerModel = computed({
get: () => props.reviewSubmitReviewer,
set: (value) => emit('update:reviewSubmitReviewer', value)
})
const riskRuleTemplateGroups = ref([])
const riskRuleTemplatesLoading = ref(false)
const riskRuleTemplateLoadFailed = ref(false)
const selectedRiskRuleTemplateId = ref('')
const flatRiskRuleTemplates = computed(() =>
riskRuleTemplateGroups.value.flatMap((group) => group.templates || [])
)
const riskRuleTemplateOptions = computed(() =>
flatRiskRuleTemplates.value.map((template) => ({
value: template.template_id,
label: `${template.group_label} / ${template.title}`
}))
)
const selectedRiskRuleTemplate = computed(() =>
flatRiskRuleTemplates.value.find((template) => template.template_id === selectedRiskRuleTemplateId.value) || null
)
watch(
() => props.riskRuleCreateOpen,
(open) => {
if (!open) {
selectedRiskRuleTemplateId.value = ''
return
}
loadRiskRuleTemplates()
}
)
async function loadRiskRuleTemplates() {
if (riskRuleTemplateGroups.value.length || riskRuleTemplatesLoading.value) {
return
}
riskRuleTemplatesLoading.value = true
riskRuleTemplateLoadFailed.value = false
try {
riskRuleTemplateGroups.value = await fetchRiskRuleTemplates()
} catch {
riskRuleTemplateLoadFailed.value = true
} finally {
riskRuleTemplatesLoading.value = false
}
}
function applyRiskRuleTemplate(templateId) {
const template = flatRiskRuleTemplates.value.find((item) => item.template_id === templateId)
if (!template || !props.riskRuleCreateForm) {
return
}
props.riskRuleCreateForm.business_domain = template.business_domain || 'expense'
props.riskRuleCreateForm.business_stage = template.business_stage || 'reimbursement'
props.riskRuleCreateForm.expense_category = template.expense_category || props.riskRuleCreateForm.expense_category
props.riskRuleCreateForm.rule_title = template.title || ''
props.riskRuleCreateForm.requires_attachment = Boolean(template.requires_attachment)
props.riskRuleCreateForm.natural_language = template.natural_language || ''
}
function formatTemplateFields(fields = []) {
const labels = fields.map((field) => field.display || field.label || field.key).filter(Boolean)
if (!labels.length) {
return '未配置字段'
}
if (labels.length <= 4) {
return labels.join('、')
}
return `${labels.slice(0, 4).join('、')}${labels.length}`
}
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
<style scoped>
.risk-rule-create-form > .span-2 {
grid-column: 1 / -1;
}
.risk-rule-template-preview {
display: grid;
gap: 7px;
padding: 12px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #f8fafc;
}
.risk-rule-template-preview__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.risk-rule-template-preview__head strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.risk-rule-template-preview__head span,
.risk-rule-template-preview small,
.risk-rule-template-note {
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.risk-rule-template-preview p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.6;
}
.risk-rule-template-note {
margin: 0;
}
</style>

View File

@@ -9,8 +9,9 @@
:show-pagination="!loading && !errorMessage && visibleEmployees.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="false"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleEmployees.length"
:total-pages="totalPages"
@@ -18,6 +19,8 @@
loading-message="正在加载数字员工资产"
loading-icon="mdi mdi-view-list-outline"
@update:current-page="currentPage = $event"
@update:page-size="pageSize = $event"
@page-size-change="changePageSize"
>
<template #filters>
<label class="list-search">
@@ -43,6 +46,19 @@
@select="selectFilter('status', $event)"
/>
<AuditPickerFilter
id="skillCategory"
title="选择技能类型"
close-label="关闭技能类型选择"
:active-filter-popover="activeFilterPopover"
:label="selectedSkillCategoryLabel"
:options="skillCategoryOptions"
:selected-value="selectedSkillCategory"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('skillCategory', $event)"
/>
<AuditPickerFilter
id="enabled"
title="选择启动状态"
@@ -172,6 +188,9 @@ const props = defineProps({
selectedStatus: { type: String, default: '' },
selectedStatusLabel: { type: String, default: '' },
statusOptions: { type: Array, default: () => [] },
selectedSkillCategory: { type: String, default: '' },
selectedSkillCategoryLabel: { type: String, default: '' },
skillCategoryOptions: { type: Array, default: () => [] },
selectedEnabledState: { type: String, default: '' },
selectedEnabledLabel: { type: String, default: '' },
enabledStateOptions: { type: Array, default: () => [] },
@@ -195,11 +214,16 @@ const emit = defineEmits([
])
const currentPage = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize)))
const pageSize = ref(10)
const pageSizeOptions = [
{ label: '10 条/页', value: 10 },
{ label: '20 条/页', value: 20 },
{ label: '50 条/页', value: 50 }
]
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize.value)))
const pagedEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize
return props.visibleEmployees.slice(start, start + pageSize)
const start = (currentPage.value - 1) * pageSize.value
return props.visibleEmployees.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
@@ -210,7 +234,7 @@ const pageNumbers = computed(() => {
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleEmployees.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
const emptyState = {
eyebrow: '数字员工',
@@ -223,7 +247,13 @@ const emptyState = {
}
watch(
() => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode],
() => [
props.keyword,
props.selectedStatus,
props.selectedSkillCategory,
props.selectedEnabledState,
props.selectedExecutionMode
],
() => {
currentPage.value = 1
}
@@ -243,6 +273,11 @@ watch(
function selectFilter(type, value) {
emit('select-filter', type, value)
}
function changePageSize(size) {
pageSize.value = Number(size) || 10
currentPage.value = 1
}
</script>
<style scoped src="../../assets/styles/views/digital-employees-view.css"></style>

View File

@@ -110,6 +110,62 @@
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察</p>
</section>
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
<div class="run-product-section-head">
<h4>待复核线索</h4>
<span>{{ riskClues.length }} </span>
</div>
<div v-if="riskClues.length" class="run-product-observation-list">
<article
v-for="item in riskClues"
:key="item.clue_id || item.clueId"
class="run-product-observation"
>
<div class="run-product-observation-head">
<span class="risk-level-pill" :class="item.risk_level || item.riskLevel">
{{ formatRiskLevel(item.risk_level || item.riskLevel) }}
</span>
<strong>{{ item.title || formatSignal(item.risk_signal || item.riskSignal) }}</strong>
<b>{{ formatConfidence(item.confidence_score ?? item.confidenceScore) }}</b>
</div>
<p>{{ item.summary || '该线索需要人工复核事实和证据。' }}</p>
<div class="run-product-tags">
<span>单据{{ item.claim_no || item.claimNo || '-' }}</span>
<span>状态待人工复核</span>
<span v-if="item.observation_key || item.observationKey">
观察{{ item.observation_key || item.observationKey }}
</span>
<span v-if="item.feedback_status || item.feedbackStatus">
反馈{{ formatFeedbackStatus(item.feedback_status || item.feedbackStatus) }}
</span>
<span>规则命中{{ (item.rule_hits || item.ruleHits || []).length }}</span>
<span>证据{{ (item.evidence_refs || item.evidenceRefs || []).length }}</span>
</div>
<p v-if="item.review_reason || item.reviewReason" class="run-product-muted-copy">
{{ item.review_reason || item.reviewReason }}
</p>
<p v-if="item.next_action || item.nextAction" class="run-product-muted-copy">
{{ item.next_action || item.nextAction }}
</p>
</article>
</div>
<p v-else class="run-product-inline-empty">本次运行没有整理出待复核线索</p>
<div v-if="feedbackRows.length" class="run-product-feedback-panel">
<div class="run-product-section-head compact">
<h4>反馈样本</h4>
<span>{{ feedbackTotal }} </span>
</div>
<ul class="run-product-feedback-list">
<li v-for="item in feedbackRows" :key="item.feedback_id || item.feedbackId">
<strong>{{ formatFeedbackStatus(item.feedback_type || item.feedbackType) }}</strong>
<span>{{ item.observation_key || item.observationKey || '未关联观察' }}</span>
<em>{{ item.actor || '系统' }}</em>
</li>
</ul>
</div>
</section>
<section v-else-if="productKind === 'employee_profile'" class="run-product-section">
<div class="run-product-section-head">
<h4>画像快照</h4>
@@ -180,6 +236,9 @@ const productSubtitle = computed(() => {
if (productKind.value === 'knowledge') {
return '展示本次知识制度整理任务的入队结果与处理范围。'
}
if (productKind.value === 'risk_clue') {
return '展示本次任务归集的事实、规则命中和待人工复核线索。'
}
return '展示本次数字员工任务产生的结构化结果。'
})
const productBadge = computed(() => {
@@ -192,8 +251,23 @@ const productBadge = computed(() => {
if (productKind.value === 'knowledge') {
return '知识整理'
}
if (productKind.value === 'risk_clue') {
return '线索归集'
}
return taskLabel.value
})
const riskClues = computed(() =>
Array.isArray(summary.value.risk_clues) ? summary.value.risk_clues : []
)
const feedbackSummary = computed(() =>
summary.value.feedback_summary && typeof summary.value.feedback_summary === 'object'
? summary.value.feedback_summary
: {}
)
const feedbackRows = computed(() =>
Array.isArray(feedbackSummary.value.recent) ? feedbackSummary.value.recent.slice(0, 6) : []
)
const feedbackTotal = computed(() => Number(feedbackSummary.value.total || feedbackRows.value.length || 0))
const documentCount = computed(() =>
Array.isArray(summary.value.document_ids) ? summary.value.document_ids.length : 0
)
@@ -223,6 +297,14 @@ const metrics = computed(() => {
buildMetric('后台 Run ID', payload.agent_run_id || '-')
]
}
if (productKind.value === 'risk_clue') {
return [
buildMetric('事实', payload.fact_count ?? (payload.facts || []).length),
buildMetric('规则命中', payload.rule_hit_count ?? (payload.rule_hits || []).length),
buildMetric('待复核线索', payload.risk_clue_count ?? riskClues.value.length),
buildMetric('反馈样本', feedbackTotal.value)
]
}
return []
})
@@ -387,6 +469,31 @@ function formatRiskLevel(value) {
return labels[String(value || '').trim()] || '未知风险'
}
function formatConfidence(value) {
const numericValue = Number(value || 0)
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return '-'
}
return `${Math.round(numericValue * 100)}%`
}
function formatFeedbackStatus(value) {
const labels = {
unreviewed: '未复核',
pending_review: '待复核',
confirm: '已确认',
confirmed: '已确认',
false_positive: '误报',
ignore: '已忽略',
ignored: '已忽略',
resolve: '已处理',
resolved: '已处理',
comment: '备注'
}
const normalized = String(value || '').trim()
return labels[normalized] || normalized || '未复核'
}
function formatSignal(value) {
const labels = {
duplicate_invoice: '重复发票',

View File

@@ -270,6 +270,10 @@
<span>返回工作记录列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
@@ -283,6 +287,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
@@ -317,6 +322,7 @@ const props = defineProps({
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
const router = useRouter()
const runs = ref([])
const loading = ref(false)
const errorMessage = ref('')
@@ -607,6 +613,14 @@ function closeWorkRecordDetail() {
detailError.value = ''
}
function openTraceCenter() {
const runId = String(selectedRunDetail.value?.run_id || selectedRunId.value || '').trim()
if (!runId) {
return
}
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
}
watch(
() => props.focusRunId,
(runId) => {