feat: 增强规则资产管理与审计页面运行时调试

后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险
规则生成模板执行和员工数据模型字段,知识库 RAG 增强本
地回退和文档提取能力,清理旧风险规则文件统一由生成引擎
管理,前端审计页面增加运行时调试面板和规则资产编辑交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-24 21:44:17 +08:00
parent 575f093c74
commit 50b1c3f9a9
113 changed files with 13896 additions and 5044 deletions

View File

@@ -16,6 +16,7 @@
:class="{
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'archive-main': activeView === 'archive',
@@ -38,6 +39,7 @@
:knowledge-summary="knowledgeSummary"
:logs-summary="logsSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:workbench-summary="workbenchSummary"
:detail-mode="detailMode"
:log-detail-mode="logDetailMode"
@@ -47,11 +49,11 @@
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openTravelCreate"
@new-application="openExpenseApplicationDialog"
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -63,6 +65,7 @@
class="workarea"
:class="{
'requests-workarea': activeView === 'requests',
'documents-workarea': activeView === 'documents',
'approval-workarea': activeView === 'approval',
'archive-workarea': activeView === 'archive',
'policies-workarea': activeView === 'policies',
@@ -86,7 +89,7 @@
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode && selectedRequest"
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
:request="selectedRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@@ -94,6 +97,19 @@
@request-deleted="handleRequestDeleted"
/>
<DocumentsCenterView
v-else-if="activeView === 'documents'"
:filtered-requests="filteredRequests"
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"
@open-document="openRequestDetail"
@create-request="openTravelCreate"
@create-application="openExpenseApplicationDialog"
@reload="reloadRequests"
@summary-change="documentSummary = $event"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@@ -130,6 +146,12 @@
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
/>
<ExpenseApplicationDialog
v-if="expenseApplicationDialogOpen"
@close="closeExpenseApplicationDialog"
@confirmed="handleExpenseApplicationConfirmed"
/>
</div>
</template>
@@ -139,10 +161,12 @@ import { computed, ref } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import ExpenseApplicationDialog from '../components/shared/ExpenseApplicationDialog.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import ArchiveCenterView from './ArchiveCenterView.vue'
@@ -160,7 +184,9 @@ import { filterNavItemsByAccess } from '../utils/accessControl.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const logsSummary = ref(null)
const documentSummary = ref(null)
const auditDetailOpen = ref(false)
const expenseApplicationDialogOpen = ref(false)
const {
activeRange,
@@ -203,6 +229,19 @@ const {
const { companyProfile, currentUser, logout } = useSystemState()
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
function openExpenseApplicationDialog() {
expenseApplicationDialogOpen.value = true
}
function closeExpenseApplicationDialog() {
expenseApplicationDialogOpen.value = false
}
function handleExpenseApplicationConfirmed() {
expenseApplicationDialogOpen.value = false
toast('费用申请字段已接入本体识别,后续会按申请审批流落单。')
}
function handleLogout() {
logout('manual')
}

View File

@@ -215,7 +215,6 @@
>
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
@@ -230,11 +229,6 @@
</div>
</div>
</div>
<div class="json-risk-editor-actions asset-detail-topbar-meta toolbar-actions">
<span class="json-risk-mode-pill" :class="selectedSkill.riskRuleSeverity">
{{ selectedSkill.riskRuleSeverityLabel }}
</span>
</div>
</header>
<div class="json-risk-editor-body">
@@ -243,19 +237,74 @@
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>这条规则的业务域风险等级创建时间和使用字段</p>
<p>这条规则的业务域风险等级创建时间上线状态和审核历史</p>
</div>
</div>
<div class="json-risk-summary-grid">
<span><strong>业务域</strong>{{ selectedSkill.category || '-' }}</span>
<span><strong>风险等级</strong>{{ selectedSkill.riskRuleSeverityLabel || '-' }}</span>
<span><strong>适用场景</strong>{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
<span><strong>创建时间</strong>{{ selectedSkill.riskRuleCreatedAt || selectedSkill.updatedAt }}</span>
<span><strong>已创建</strong>{{ selectedSkill.riskRuleAgeLabel || '-' }}</span>
<span>
<strong>使用字段</strong>
{{ selectedSkill.riskRuleFieldSummary || '-' }}
</span>
<div class="json-risk-meta-grid">
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">业务域</span>
<span class="json-risk-meta-value">{{ selectedSkill.category || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">适用场景</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">风险等级</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.riskRuleSeverity">
{{ selectedSkill.riskRuleSeverityLabel || '-' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否上线</span>
<span class="json-risk-meta-value">
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineLabel === '是' }">
<span class="indicator-dot"></span>
{{ selectedSkill.isOnlineLabel || '否' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否启用</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.isEnabledTone">
{{ selectedSkill.isEnabledLabel || '-' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">测试状态</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="riskRuleTestPassed ? 'test-passed' : 'test-pending'">
{{ riskRuleTestPassed ? '已确认通过' : '待测试确认' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">发布人</span>
<span class="json-risk-meta-value">{{ selectedSkill.publisher || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">审核人</span>
<span class="json-risk-meta-value">{{ selectedSkill.reviewer || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">创建时间</span>
<span class="json-risk-meta-value">
{{ selectedSkill.riskRuleCreatedAt || selectedSkill.updatedAt }}
<span class="meta-value-hint" v-if="selectedSkill.riskRuleAgeLabel">({{ selectedSkill.riskRuleAgeLabel }})</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">上线时间</span>
<span class="json-risk-meta-value">{{ selectedSkill.publishedAt || '-' }}</span>
</div>
<div class="json-risk-meta-item full-width">
<span class="json-risk-meta-label">使用字段</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleFieldSummary || '-' }}</span>
</div>
</div>
</article>
@@ -618,25 +667,88 @@
<button class="back-action" type="button" @click="closeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回能力列表</span>
</button>
<div v-if="selectedSkillIsRule" class="detail-action-group">
<button
v-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canDownloadSpreadsheet"
@click="downloadSpreadsheetFile"
>
</button>
<div v-if="selectedSkillIsRule" class="detail-action-group">
<template v-if="selectedSkillUsesJsonRisk">
<button
class="minor-action"
type="button"
:disabled="!canOpenRiskRuleTest"
@click="openRiskRuleTestDialog"
>
<i class="mdi mdi-flask-outline"></i>
<span>测试规则</span>
</button>
<button
v-if="canToggleRiskRuleEnabled"
class="minor-action enable-action"
:class="{ 'is-on': selectedSkill.isEnabledValue }"
type="button"
:disabled="detailBusy"
@click="toggleSelectedRiskRuleEnabled"
>
<i :class="selectedSkill.isEnabledValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
<span>{{ selectedSkill.isEnabledValue ? '已启用' : '已停用' }}</span>
</button>
<button
v-if="selectedSkillUsesJsonRisk && canEditSelected"
class="minor-action danger-action"
type="button"
:disabled="!canDeleteRiskRule"
@click="openDeleteRiskRuleDialog"
:title="canDeleteRiskRule ? '删除未发布规则' : '已发布过的规则不能删除'"
>
<i class="mdi mdi-delete-outline"></i>
<span>删除规则</span>
</button>
<button
v-if="canEditSelected && !riskRuleInReview"
class="major-action"
type="button"
:disabled="!canSubmitRiskRuleReview"
@click="openSubmitReviewDialog"
>
<i class="mdi mdi-send-outline"></i>
<span>提交审核</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="minor-action"
type="button"
:disabled="!canReturnRiskRule"
@click="openReturnRiskRuleDialog"
>
<i class="mdi mdi-keyboard-return"></i>
<span>回退规则</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="major-action"
type="button"
:disabled="!canPublishRiskRule"
@click="openPublishRiskRuleDialog"
>
<i class="mdi mdi-rocket-launch-outline"></i>
<span>发布上线</span>
</button>
</template>
<button
v-else-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canDownloadSpreadsheet"
@click="downloadSpreadsheetFile"
>
<i class="mdi mdi-file-download-outline"></i>
<span>{{ actionState === 'download-spreadsheet' ? '下载中...' : '下载表格' }}</span>
</button>
<button
v-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canUploadSpreadsheet"
@click="triggerSpreadsheetUpload"
</button>
<button
v-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canUploadSpreadsheet"
@click="triggerSpreadsheetUpload"
>
<i class="mdi mdi-file-upload-outline"></i>
<span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span>
@@ -732,18 +844,22 @@
<span class="picker-label">{{ selectedOwnerLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'owner'"
class="picker-popover"
role="dialog"
aria-label="选择负责人"
>
<header>
<strong>选择负责人</strong>
<button type="button" aria-label="关闭负责人选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div
v-if="activeFilterPopover === 'owner'"
class="picker-popover"
role="dialog"
:aria-label="activeType === 'riskRules' ? '选择审核人' : '选择负责人'"
>
<header>
<strong>{{ activeType === 'riskRules' ? '选择审核人' : '选择负责人' }}</strong>
<button
type="button"
:aria-label="activeType === 'riskRules' ? '关闭审核人选择' : '关闭负责人选择'"
@click="closeFilterPopover"
>
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in ownerOptions"
@@ -800,11 +916,95 @@
</button>
</div>
</div>
</div>
<div
v-if="showStatusFilter"
class="picker-filter"
</div>
<div
v-if="showOnlineFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'online' }"
>
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'online'"
aria-haspopup="dialog"
@click="toggleFilterPopover('online')"
>
<span class="picker-label">{{ selectedOnlineStateLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'online'"
class="picker-popover"
role="dialog"
aria-label="选择上线状态"
>
<header>
<strong>选择上线状态</strong>
<button type="button" aria-label="关闭上线状态选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in onlineStateOptions"
:key="option.value || 'all-online-state'"
type="button"
class="picker-option"
:class="{ active: selectedOnlineState === option.value }"
@click="selectFilter('online', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div
v-if="showEnabledFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'enabled' }"
>
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'enabled'"
aria-haspopup="dialog"
@click="toggleFilterPopover('enabled')"
>
<span class="picker-label">{{ selectedEnabledStateLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'enabled'"
class="picker-popover"
role="dialog"
aria-label="选择启用状态"
>
<header>
<strong>选择启用状态</strong>
<button type="button" aria-label="关闭启用状态选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in enabledStateOptions"
:key="option.value || 'all-enabled-state'"
type="button"
class="picker-option"
:class="{ active: selectedEnabledState === option.value }"
@click="selectFilter('enabled', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div
v-if="showStatusFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'status' }"
>
@@ -909,10 +1109,12 @@
<th>{{ tableColumns.owner }}</th>
<th>{{ tableColumns.scope }}</th>
<th v-if="showRuntimeColumn">{{ tableColumns.runtime }}</th>
<th v-if="showVersionColumn">{{ tableColumns.version }}</th>
<th v-if="showStatusColumn">状态</th>
<th v-if="showMetricColumn">{{ tableColumns.metric }}</th>
<th>最近更新</th>
<th v-if="showVersionColumn">{{ tableColumns.version }}</th>
<th v-if="showStatusColumn">状态</th>
<th v-if="showMetricColumn">{{ tableColumns.metric }}</th>
<th v-if="showOnlineColumn">是否上线</th>
<th v-if="showEnabledColumn">是否启用</th>
<th>{{ tableColumns.updatedAt || '最近更新' }}</th>
</tr>
</thead>
<tbody>
@@ -934,10 +1136,16 @@
<td>{{ skill.owner }}</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td v-if="showRuntimeColumn">{{ skill.model }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn"><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td>{{ skill.updatedAt }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn"><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td v-if="showOnlineColumn">
<span class="status-pill" :class="skill.isOnlineTone">{{ skill.isOnlineLabel }}</span>
</td>
<td v-if="showEnabledColumn">
<span class="status-pill" :class="skill.isEnabledTone">{{ skill.isEnabledLabel }}</span>
</td>
<td>{{ skill.updatedAt }}</td>
</tr>
</tbody>
</table>
@@ -990,6 +1198,32 @@
</option>
</select>
</label>
<label v-if="riskRuleCreateForm.business_domain === 'expense'" class="span-2">
<span>费用领域</span>
<select
v-model="riskRuleCreateForm.expense_category"
:disabled="riskRuleCreateBusy"
>
<option
v-for="option in riskRuleExpenseCategoryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label class="risk-rule-create-toggle span-2">
<input
v-model="riskRuleCreateForm.requires_attachment"
type="checkbox"
:disabled="riskRuleCreateBusy"
/>
<span>
<strong>测试时需要上传附件</strong>
<small>适用于依赖发票行程单合同等单据 OCR 字段的规则不勾选则测试窗口不显示附件上传</small>
</span>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea
@@ -1001,6 +1235,81 @@
</div>
</ConfirmDialog>
<RiskRuleTestDialog
:open="riskRuleTestOpen"
:rule="selectedSkill"
@close="closeRiskRuleTestDialog"
@report-saved="handleRiskRuleReportSaved"
/>
<ConfirmDialog
:open="riskRuleDeleteOpen"
badge="删除规则"
badge-tone="danger"
title="删除未发布风险规则"
description="该操作会删除规则草稿、版本记录和关联 JSON 文件。只有从未发布过的规则允许删除。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"
:busy="actionState === 'delete-risk-rule'"
@close="closeDeleteRiskRuleDialog"
@confirm="deleteSelectedRiskRule"
>
<div class="risk-rule-action-confirm">
<span>规则名称</span>
<strong>{{ selectedSkill?.name }}</strong>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="riskRuleReturnOpen"
badge="回退规则"
badge-tone="warning"
title="回退风险规则"
description="回退后规则会回到草稿状态,编写人需要根据原因重新调整并测试。"
cancel-text="取消"
confirm-text="确认回退"
busy-text="回退中..."
confirm-tone="warning"
confirm-icon="mdi mdi-keyboard-return"
:busy="actionState === 'return-risk-rule'"
@close="closeReturnRiskRuleDialog"
@confirm="returnSelectedRiskRule"
>
<label class="risk-rule-action-note">
<span>回退原因</span>
<textarea
v-model="riskRuleReturnNote"
rows="4"
:disabled="actionState === 'return-risk-rule'"
placeholder="请说明需要编写人调整的规则问题"
></textarea>
</label>
</ConfirmDialog>
<ConfirmDialog
:open="riskRulePublishOpen"
badge="发布上线"
badge-tone="info"
title="发布风险规则"
description="发布后该规则会进入真实业务风险扫描,只加载正式上线规则。"
cancel-text="取消"
confirm-text="确认发布"
busy-text="发布中..."
confirm-tone="primary"
confirm-icon="mdi mdi-rocket-launch-outline"
:busy="actionState === 'publish-risk-rule'"
@close="closePublishRiskRuleDialog"
@confirm="publishSelectedRiskRule"
>
<div class="risk-rule-action-confirm">
<span>测试状态</span>
<strong>{{ riskRuleTestPassed ? '已确认通过' : '未确认通过' }}</strong>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="Boolean(versionSwitchTarget)"
badge="切换版本"
@@ -1076,11 +1385,18 @@
</option>
</select>
</label>
<p v-if="!reviewSubmitReviewerLoading && !hasReviewSubmitReviewers" class="review-submit-hint">
当前没有可选的高级管理员请先在员工管理中配置具备管理员角色的员工
</p>
</div>
</ConfirmDialog>
<p v-if="!reviewSubmitReviewerLoading && !hasReviewSubmitReviewers" class="review-submit-hint">
当前没有可选的高级管理员请先在员工管理中配置具备管理员角色的员工
</p>
<div v-if="selectedSkillUsesJsonRisk" class="review-submit-test-state">
<span>测试确认</span>
<strong :class="{ passed: riskRuleTestPassed }">
{{ riskRuleTestPassed ? '当前版本已通过测试确认' : '当前版本尚未确认测试通过' }}
</strong>
<p>只有保存测试报告的风险规则才能提交给高级管理人员审核</p>
</div>
</div>
</ConfirmDialog>
<Transition name="drawer-fade">
<div v-if="versionTimelineOpen" class="rule-drawer-backdrop" @click.self="closeVersionTimeline">

View File

@@ -0,0 +1,788 @@
<template>
<section class="documents-page">
<article class="documents-list panel">
<nav class="status-tabs document-scope-tabs" aria-label="单据工作视角">
<button
v-for="tab in scopeTabItems"
:key="tab.value"
type="button"
:class="{ active: activeScopeTab === tab.value }"
@click="activeScopeTab = tab.value"
>
<span>{{ tab.label }}</span>
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
</span>
</button>
</nav>
<div class="document-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" :placeholder="activeFilterConfig.searchPlaceholder" />
</div>
<div class="document-status-filter" :aria-label="activeFilterConfig.statusTitle">
<div class="document-filter status-dropdown-filter" :class="{ open: openFilterKey === 'status' }">
<button
class="filter-btn status-filter-trigger"
type="button"
:aria-expanded="openFilterKey === 'status'"
@click="toggleFilter('status')"
>
<i class="mdi mdi-filter-variant"></i>
<span>{{ statusFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'status'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="单据状态"
>
<button
v-for="option in statusFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="activeStatusTab === option.value"
:class="{ active: activeStatusTab === option.value }"
@click="selectStatusTab(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div v-if="showDocumentTypeFilter" class="document-filter">
<button class="filter-btn" type="button" @click="toggleFilter('documentType')">
<span>{{ documentTypeFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="openFilterKey === 'documentType'" class="document-filter-menu">
<button
v-for="option in documentTypeOptions"
:key="option.value"
type="button"
:class="{ active: activeDocumentType === option.value }"
@click="selectDocumentType(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="document-filter">
<button class="filter-btn" type="button" @click="toggleFilter('scene')">
<span>{{ sceneFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="openFilterKey === 'scene'" class="document-filter-menu">
<button
v-for="option in sceneFilterOptions"
:key="option.value"
type="button"
:class="{ active: activeScene === option.value }"
@click="selectScene(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="clearDateRange">清空</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
</div>
<div v-if="[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab)" class="document-actions">
<button v-if="activeScopeTab === DOCUMENT_SCOPE_APPLICATION" class="create-request-btn" type="button" @click="emit('create-application')">
<i class="mdi mdi-file-plus-outline"></i>
<span>发起申请</span>
</button>
<button v-if="activeScopeTab === DOCUMENT_SCOPE_REIMBURSEMENT" class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span>
</button>
</div>
</div>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="showLoading" class="table-state">
<TableLoadingState
title="单据数据同步中"
message="正在汇总当前报销、审批待办与归档单据"
icon="mdi mdi-file-document-multiple-outline"
/>
</div>
<div v-else-if="showError" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>单据中心加载失败</strong>
<p>{{ errorMessage }}</p>
<button class="retry-btn" type="button" @click="reloadAll">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup>
<col class="col-id">
<col class="col-created">
<col v-if="showStayTimeColumn" class="col-stay">
<col class="col-doc-type">
<col class="col-scene">
<col class="col-title">
<col class="col-amount">
<col class="col-node">
<col class="col-status">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>创建时间</th>
<th v-if="showStayTimeColumn">停留时间</th>
<th>单据类型</th>
<th>费用场景</th>
<th>事项</th>
<th>金额</th>
<th>当前环节</th>
<th>状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
<td><strong class="doc-id">{{ row.documentNo }}</strong></td>
<td>{{ row.createdAtDisplay }}</td>
<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.reason }}</td>
<td>{{ row.amountDisplay }}</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td>{{ row.updatedAtDisplay }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} / <i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</article>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import {
extractDateText,
formatDocumentListTime,
resolveDocumentSortTime,
resolveDocumentStayTimeDisplay
} from '../utils/documentCenterTime.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const DOCUMENT_TYPE_ALL = 'all'
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const SCENE_ALL = 'all'
const DOCUMENT_SCOPE_APPLICATION = '申请单'
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [
DOCUMENT_SCOPE_APPLICATION,
DOCUMENT_SCOPE_REIMBURSEMENT,
DOCUMENT_SCOPE_REVIEW,
DOCUMENT_SCOPE_ARCHIVE
]
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_APPLICATION]: {
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
sceneFallbackLabel: '申请场景',
dateLabel: '申请时间',
statusTitle: '申请状态',
statusTabs: ['全部', '草稿', '审批中', '已完成'],
showDocumentType: false
},
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
sceneFallbackLabel: '费用场景',
dateLabel: '报销时间',
statusTitle: '报销状态',
statusTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_REVIEW]: {
searchPlaceholder: '搜索审核单号、事项、当前环节...',
sceneFallbackLabel: '审核场景',
dateLabel: '审核时间',
statusTitle: '审核状态',
statusTabs: ['全部', '审批中', '待补充', '已完成'],
showDocumentType: false
},
[DOCUMENT_SCOPE_ARCHIVE]: {
searchPlaceholder: '搜索归档单号、事项、费用场景...',
sceneFallbackLabel: '归档场景',
dateLabel: '归档时间',
statusTitle: '归档状态',
statusTabs: ['全部', '已完成'],
showDocumentType: false
}
}
const pageSizes = [10, 20, 50]
const documentTypeOptions = [
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
]
const props = defineProps({
filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
})
const emit = defineEmits([
'open-document',
'create-request',
'create-application',
'reload',
'summary-change'
])
const activeScopeTab = ref(DOCUMENT_SCOPE_APPLICATION)
const activeStatusTab = ref('全部')
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
const activeScene = ref(SCENE_ALL)
const openFilterKey = ref('')
const listKeyword = ref('')
const datePopover = ref(false)
const rangeStart = ref('')
const rangeEnd = ref('')
const appliedStart = ref('')
const appliedEnd = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const pageSizeOpen = ref(false)
const archiveRows = ref([])
const approvalRows = ref([])
const supportingLoading = ref(false)
const supportingError = ref('')
const activeFilterConfig = computed(() =>
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
)
const showDocumentTypeFilter = computed(() => Boolean(activeFilterConfig.value.showDocumentType))
const documentTypeFilterLabel = computed(() =>
documentTypeOptions.find((item) => item.value === activeDocumentType.value)?.label || '单据类型'
)
const statusFilterOptions = computed(() =>
activeFilterConfig.value.statusTabs.map((tab) => ({
value: tab,
label: tab === '全部' ? '全部状态' : tab
}))
)
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) {
return `${appliedStart.value} ~ ${appliedEnd.value}`
}
return activeFilterConfig.value.dateLabel
})
const ownedRows = computed(() =>
props.filteredRequests
.map((item) => buildDocumentRow(item, { source: 'owned' }))
.filter(Boolean)
)
const allSummaryRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value, ...archiveRows.value]))
const scopeNewCountMap = computed(() => ({
[DOCUMENT_SCOPE_APPLICATION]: allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION).length,
[DOCUMENT_SCOPE_REIMBURSEMENT]: ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT).length,
[DOCUMENT_SCOPE_REVIEW]: approvalRows.value.length,
[DOCUMENT_SCOPE_ARCHIVE]: archiveRows.value.length
}))
const scopeTabItems = computed(() =>
scopeTabs.map((tab) => ({
value: tab,
label: tab,
badgeCount: scopeNewCountMap.value[tab] || 0
}))
)
const activeScopeRows = computed(() => {
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
return ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT)
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REVIEW) {
return approvalRows.value
}
if (activeScopeTab.value === DOCUMENT_SCOPE_ARCHIVE) {
return archiveRows.value
}
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
})
const sceneFilterOptions = computed(() => {
const sceneMap = new Map([[SCENE_ALL, activeFilterConfig.value.sceneFallbackLabel]])
activeScopeRows.value.forEach((row) => {
if (row.typeCode && row.typeLabel) {
sceneMap.set(row.typeCode, row.typeLabel)
}
})
return Array.from(sceneMap, ([value, label]) => ({ value, label }))
})
const sceneFilterLabel = computed(() =>
sceneFilterOptions.value.find((item) => item.value === activeScene.value)?.label || activeFilterConfig.value.sceneFallbackLabel
)
const statusFilterLabel = computed(() =>
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部状态'
)
const filteredRows = computed(() => {
const keyword = listKeyword.value.trim().toLowerCase()
return activeScopeRows.value.filter((row) => {
const matchesKeyword = !keyword || [
row.documentNo,
row.documentTypeLabel,
row.typeLabel,
row.reason,
row.node,
row.statusLabel
].filter(Boolean).join('').toLowerCase().includes(keyword)
const matchesDocumentType =
!showDocumentTypeFilter.value
|| activeDocumentType.value === DOCUMENT_TYPE_ALL
|| row.documentTypeCode === activeDocumentType.value
const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value
const matchesStatus = matchesStatusTab(row, activeStatusTab.value)
const matchesDateRange = matchesAppliedDateRange(row)
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
const showLoading = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)
const showError = computed(() => Boolean(props.error) && !visibleRows.value.length)
const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。')
const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0)
const showTable = computed(() => !showLoading.value && !showError.value && visibleRows.value.length > 0)
const showStayTimeColumn = computed(() =>
[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REVIEW].includes(activeScopeTab.value)
)
const documentSummary = computed(() => {
const rows = allSummaryRows.value
return {
total: rows.length,
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
toProcess: approvalRows.value.length,
archived: archiveRows.value.length
}
})
const emptyState = computed(() => {
const filtered = hasActiveFilters()
if (
activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION
|| activeDocumentType.value === DOCUMENT_TYPE_APPLICATION
) {
return {
eyebrow: '申请单',
title: '当前还没有申请单数据',
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
icon: 'mdi mdi-file-sign-outline',
actionLabel: '发起申请',
actionIcon: 'mdi mdi-file-plus-outline',
tone: 'sky',
artLabel: 'APPLY',
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
}
}
return {
eyebrow: filtered ? '筛选结果为空' : '单据中心',
title: filtered ? '没有符合当前条件的单据' : `${activeScopeTab.value}”里暂时没有单据`,
desc: filtered
? '可以清空当前分类下的筛选条件后再看看。'
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
actionLabel: filtered ? '清空筛选' : '发起报销',
actionIcon: filtered ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-plus-circle-outline',
tone: filtered ? 'sky' : 'emerald',
artLabel: filtered ? 'FILTER' : 'DOCS',
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
}
})
function buildDocumentRow(request, options = {}) {
const normalized = normalizeRequestForUi(request)
if (!normalized) {
return null
}
const archived = Boolean(options.archived)
const statusGroup = resolveStatusGroup(normalized, archived)
const statusLabel = archived ? '已归档' : resolveStatusLabel(normalized, statusGroup)
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
return {
...normalized,
rawRequest: request,
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT,
documentTypeLabel: '报销单',
claimId,
documentNo,
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
statusGroup,
statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
source: options.source || 'owned',
archived,
createdAtDisplay: formatDocumentListTime(createdAtSource),
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource)
}
}
function resolveStatusGroup(row, archived) {
if (archived) return 'completed'
if (row.approvalKey === 'draft') return 'draft'
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
if (row.approvalKey === 'supplement') return 'supplement'
if (row.approvalKey === 'in_progress') return 'in_progress'
if (row.approvalKey === 'completed') return 'completed'
return 'other'
}
function resolveStatusLabel(row, statusGroup) {
if (statusGroup === 'pending_submit') return '待提交'
return row.approval || row.approvalStatus || '处理中'
}
function resolveStatusTone(row, statusGroup) {
if (statusGroup === 'pending_submit') return 'warning'
return row.approvalTone || 'neutral'
}
function matchesStatusTab(row, tab) {
if (tab === '全部') return true
if (tab === '草稿') return row.statusGroup === 'draft'
if (tab === '待提交') return row.statusGroup === 'pending_submit'
if (tab === '审批中') return row.statusGroup === 'in_progress'
if (tab === '待补充') return row.statusGroup === 'supplement'
if (tab === '已完成') return row.statusGroup === 'completed'
return true
}
function matchesAppliedDateRange(row) {
if (!appliedStart.value || !appliedEnd.value) {
return true
}
const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
return Boolean(date) && date >= appliedStart.value && date <= appliedEnd.value
}
function mergeDocumentRows(rows) {
const rowMap = new Map()
rows.filter(Boolean).forEach((row) => {
const key = row.claimId || row.documentNo || row.documentKey
const current = rowMap.get(key)
if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
rowMap.set(key, row)
}
})
return Array.from(rowMap.values()).sort((left, right) => right.sortTime - left.sortTime)
}
function resolveSourcePriority(row) {
if (row.archived) return 3
if (row.source === 'approval') return 2
return 1
}
function hasActiveFilters() {
return Boolean(
listKeyword.value.trim()
|| activeStatusTab.value !== '全部'
|| (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL)
|| activeScene.value !== SCENE_ALL
|| appliedStart.value
|| appliedEnd.value
)
}
function toggleFilter(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
}
function selectDocumentType(value) {
activeDocumentType.value = value
openFilterKey.value = ''
}
function selectScene(value) {
activeScene.value = value
openFilterKey.value = ''
}
function selectStatusTab(value) {
activeStatusTab.value = value
openFilterKey.value = ''
}
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) {
return
}
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
function clearDateRange() {
rangeStart.value = ''
rangeEnd.value = ''
appliedStart.value = ''
appliedEnd.value = ''
datePopover.value = false
}
function resetFilters() {
activeStatusTab.value = '全部'
activeDocumentType.value = DOCUMENT_TYPE_ALL
activeScene.value = SCENE_ALL
listKeyword.value = ''
clearDateRange()
openFilterKey.value = ''
currentPage.value = 1
}
function handleEmptyAction() {
if (activeDocumentType.value === DOCUMENT_TYPE_APPLICATION) {
emit('create-application')
return
}
if (hasActiveFilters()) {
resetFilters()
return
}
emit('create-request')
}
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
function openDocument(row) {
emit('open-document', row.rawRequest || row)
}
async function loadSupportingRows() {
supportingLoading.value = true
supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([
fetchApprovalExpenseClaims(),
fetchArchivedExpenseClaims()
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = Array.isArray(approvalResult.value)
? approvalResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
} else {
approvalRows.value = []
}
if (archiveResult.status === 'fulfilled') {
archiveRows.value = Array.isArray(archiveResult.value)
? archiveResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
.filter(Boolean)
: []
} else {
archiveRows.value = []
supportingError.value = archiveResult.reason instanceof Error
? archiveResult.reason.message
: '归档数据加载失败。'
}
supportingLoading.value = false
}
function reloadAll() {
emit('reload')
void loadSupportingRows()
}
watch(
[activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd],
() => {
currentPage.value = 1
pageSizeOpen.value = false
}
)
watch(activeFilterConfig, () => {
openFilterKey.value = ''
datePopover.value = false
pageSizeOpen.value = false
if (!showDocumentTypeFilter.value) {
activeDocumentType.value = DOCUMENT_TYPE_ALL
}
if (!statusFilterOptions.value.some((item) => item.value === activeStatusTab.value)) {
activeStatusTab.value = '全部'
}
})
watch(sceneFilterOptions, (options) => {
if (!options.some((item) => item.value === activeScene.value)) {
activeScene.value = SCENE_ALL
}
})
watch(documentSummary, (summary) => {
emit('summary-change', summary)
}, { immediate: true })
onMounted(() => {
void loadSupportingRows()
})
</script>
<style scoped src="../assets/styles/views/documents-center-view.css"></style>

View File

@@ -9,7 +9,7 @@
<article class="panel logs-console" :class="{ 'without-toolbar': activeTab === 'hermes' }">
<div class="console-tabs" role="tablist" aria-label="日志类型切换">
<button type="button" :class="{ active: activeTab === 'hermes' }" @click="activeTab = 'hermes'">
Hermes 运行日志
数字员工日志
</button>
<button type="button" :class="{ active: activeTab === 'system' }" @click="activeTab = 'system'">
系统日志
@@ -88,12 +88,11 @@
<thead>
<tr>
<th>时间</th>
<th>来源</th>
<th>模块</th>
<th>级别</th>
<th>状态</th>
<th>摘要</th>
<th>Trace ID</th>
<th>状态</th>
</tr>
</thead>
<tbody>
@@ -103,19 +102,12 @@
@click="selectRun(run.run_id)"
>
<td>{{ formatDateTime(run.started_at) }}</td>
<td>Hermes</td>
<td>{{ resolveRunModuleLabel(run) }}</td>
<td>
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(run))">
{{ resolveRunLevel(run) }}
</span>
</td>
<td class="summary-cell">
<strong>{{ resolveRunTitle(run) }}</strong>
<span>{{ formatSummary(run.result_summary) }}</span>
<em class="summary-meta">{{ resolveRunSummaryMeta(run) }}</em>
</td>
<td class="trace-cell">{{ run.run_id }}</td>
<td>
<div class="status-stack">
<span class="status-pill" :class="resolveStatusTone(run)">
@@ -124,6 +116,12 @@
<span class="status-note">{{ resolveRunStatusNote(run) }}</span>
</div>
</td>
<td class="summary-cell">
<strong>{{ resolveRunTitle(run) }}</strong>
<span>{{ formatSummary(run.result_summary) }}</span>
<em class="summary-meta">{{ resolveRunSummaryMeta(run) }}</em>
</td>
<td class="trace-cell">{{ run.run_id }}</td>
</tr>
</tbody>
</table>
@@ -151,9 +149,9 @@
<th>级别</th>
<th>事件类型</th>
<th>模块</th>
<th>结果</th>
<th>摘要</th>
<th>Request ID</th>
<th>结果</th>
</tr>
</thead>
<tbody>
@@ -170,16 +168,16 @@
</td>
<td>{{ entry.event_type }}</td>
<td>{{ entry.logger || '未标记' }}</td>
<td class="summary-cell">
<strong>{{ entry.summary || entry.message }}</strong>
<span>{{ formatSummary(entry.message) }}</span>
</td>
<td class="trace-cell">{{ entry.request_id || '—' }}</td>
<td>
<span class="status-pill" :class="resolveSystemOutcomeTone(entry.outcome)">
{{ entry.outcome }}
</span>
</td>
<td class="summary-cell">
<strong>{{ entry.summary || entry.message }}</strong>
<span>{{ formatSummary(entry.message) }}</span>
</td>
<td class="trace-cell">{{ entry.request_id || '—' }}</td>
</tr>
</tbody>
</table>

View File

@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { fetchEmployees } from '../../services/employees.js'
import RiskRuleFlowDiagram from '../../components/shared/RiskRuleFlowDiagram.vue'
import RiskRuleTestDialog from '../../components/shared/RiskRuleTestDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
@@ -11,6 +12,7 @@ import {
activateAgentAsset,
createAgentAssetReview,
createAgentAssetVersion,
deleteAgentAsset,
fetchAgentAssetDetail,
fetchAgentAssets,
fetchAgentAssetSpreadsheetBlob,
@@ -20,9 +22,12 @@ import {
fetchAgentAssetVersionTimeline,
fetchAgentRuns,
generateRiskRuleAsset,
publishRiskRuleAsset,
returnRiskRuleAsset,
saveAgentAssetRuleJson,
importAgentAssetSpreadsheetContent,
restoreAgentAssetVersion,
setRiskRuleAssetEnabled,
updateAgentAsset
} from '../../services/agentAssets.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
@@ -39,6 +44,8 @@ import {
import {
TAB_META,
STATUS_OPTIONS,
ENABLED_STATE_OPTIONS,
ONLINE_STATE_OPTIONS,
RISK_SCENARIO_OPTIONS,
normalizeText,
readConfigJson,
@@ -63,6 +70,7 @@ import {
import {
createDefaultRiskRuleForm,
RISK_RULE_CREATE_DOMAIN_OPTIONS,
RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
RISK_RULE_LEVEL_OPTIONS
} from './auditViewRiskRuleModel.js'
@@ -71,6 +79,7 @@ export default {
components: {
ConfirmDialog,
RiskRuleFlowDiagram,
RiskRuleTestDialog,
TableLoadingState,
TableEmptyState
},
@@ -93,6 +102,8 @@ export default {
const selectedOwner = ref('')
const selectedStatus = ref('')
const selectedRiskScenario = ref('')
const selectedOnlineState = ref('')
const selectedEnabledState = ref('')
const loading = ref(false)
const errorMessage = ref('')
const detailLoading = ref(false)
@@ -105,6 +116,11 @@ export default {
const reviewSubmitReviewerOptions = ref([])
const riskRuleCreateOpen = ref(false)
const riskRuleCreateForm = ref(createDefaultRiskRuleForm())
const riskRuleTestOpen = ref(false)
const riskRuleDeleteOpen = ref(false)
const riskRuleReturnOpen = ref(false)
const riskRulePublishOpen = ref(false)
const riskRuleReturnNote = ref('')
const runLoading = ref(false)
const runs = ref([])
const spreadsheetUploadInput = ref(null)
@@ -146,6 +162,8 @@ export default {
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const showOnlineColumn = computed(() => activeType.value === 'riskRules')
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
@@ -165,6 +183,46 @@ export default {
const canCreateRiskRule = computed(
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
)
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
const riskRuleInReview = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
)
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
Boolean(selectedSkill.value?.id) &&
!detailBusy.value
)
const canDeleteRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
!detailBusy.value
)
const canSubmitRiskRuleReview = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canSubmitReview.value &&
!riskRuleInReview.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && riskRuleInReview.value
)
const canPublishRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canManageSelected.value &&
riskRuleInReview.value &&
riskRuleTestPassed.value
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && !detailBusy.value
)
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
const isDisplayingWorkingVersion = computed(
@@ -276,7 +334,7 @@ export default {
const ownerOptions = computed(() => {
const uniqueOwners = [...new Set(currentAssets.value.map((item) => item.owner).filter(Boolean))]
return [
{ value: '', label: '全部负责人' },
{ value: '', label: activeType.value === 'riskRules' ? '全部审核人' : '全部负责人' },
...uniqueOwners.map((value) => ({
value,
label: value
@@ -287,7 +345,9 @@ export default {
() => domainOptions.value.find((item) => item.value === selectedDomain.value)?.label || '业务域'
)
const selectedOwnerLabel = computed(
() => ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label || '负责人'
() =>
ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label ||
(activeType.value === 'riskRules' ? '审核人' : '负责人')
)
const selectedStatusLabel = computed(
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
@@ -296,11 +356,23 @@ export default {
['financialRules', 'riskRules'].includes(activeType.value)
)
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
const showOnlineFilter = computed(() => activeType.value === 'riskRules')
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
const selectedRiskScenarioLabel = computed(
() =>
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
'使用场景'
)
const selectedOnlineStateLabel = computed(
() =>
ONLINE_STATE_OPTIONS.find((item) => item.value === selectedOnlineState.value)?.label ||
'是否上线'
)
const selectedEnabledStateLabel = computed(
() =>
ENABLED_STATE_OPTIONS.find((item) => item.value === selectedEnabledState.value)?.label ||
'是否启用'
)
const activeFilterTokens = computed(() => {
const tokens = []
@@ -313,8 +385,14 @@ export default {
if (showStatusFilter.value && selectedStatus.value) {
tokens.push(`状态:${resolveStatusMeta(selectedStatus.value).label}`)
}
if (showOnlineFilter.value && selectedOnlineState.value) {
tokens.push(`是否上线:${selectedOnlineStateLabel.value}`)
}
if (showEnabledFilter.value && selectedEnabledState.value) {
tokens.push(`是否启用:${selectedEnabledStateLabel.value}`)
}
if (selectedOwner.value) {
tokens.push(`负责人${selectedOwner.value}`)
tokens.push(`${activeType.value === 'riskRules' ? '审核人' : '负责人'}${selectedOwner.value}`)
}
if (keyword.value.trim()) {
tokens.push(`搜索:${keyword.value.trim()}`)
@@ -326,9 +404,11 @@ export default {
const hasFilters = activeFilterTokens.value.length > 0
const supportedFilters = [
'业务域',
'负责人',
activeType.value === 'riskRules' ? '审核人' : '负责人',
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
...(showStatusFilter.value ? ['状态'] : []),
...(showOnlineFilter.value ? ['是否上线'] : []),
...(showEnabledFilter.value ? ['是否启用'] : []),
'关键词'
]
@@ -409,8 +489,12 @@ export default {
selectedOwner: selectedOwner.value,
selectedStatus: selectedStatus.value,
selectedRiskScenario: selectedRiskScenario.value,
selectedOnlineState: selectedOnlineState.value,
selectedEnabledState: selectedEnabledState.value,
showStatusFilter: showStatusFilter.value,
showRiskScenarioFilter: showRiskScenarioFilter.value
showRiskScenarioFilter: showRiskScenarioFilter.value,
showOnlineFilter: showOnlineFilter.value,
showEnabledFilter: showEnabledFilter.value
})
)
@@ -455,6 +539,8 @@ export default {
selectedOwner.value = ''
selectedStatus.value = ''
selectedRiskScenario.value = ''
selectedOnlineState.value = ''
selectedEnabledState.value = ''
activeFilterPopover.value = ''
}
@@ -488,6 +574,12 @@ export default {
if (name === 'riskScenario') {
selectedRiskScenario.value = value
}
if (name === 'online') {
selectedOnlineState.value = value
}
if (name === 'enabled') {
selectedEnabledState.value = value
}
closeFilterPopover()
}
@@ -536,7 +628,11 @@ export default {
const detail = await generateRiskRuleAsset(
{
business_domain: riskRuleCreateForm.value.business_domain,
expense_category: riskRuleCreateForm.value.business_domain === 'expense'
? riskRuleCreateForm.value.expense_category
: null,
risk_level: riskRuleCreateForm.value.risk_level,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
natural_language: naturalLanguage
},
{ actor: resolveActor() }
@@ -1105,6 +1201,11 @@ export default {
versionSwitchTarget.value = null
versionTimelineOpen.value = false
versionTimelineItems.value = []
riskRuleTestOpen.value = false
riskRuleDeleteOpen.value = false
riskRuleReturnOpen.value = false
riskRulePublishOpen.value = false
riskRuleReturnNote.value = ''
}
function openVersionSwitch(version) {
@@ -1299,6 +1400,10 @@ export default {
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
toast('请先在“测试规则”中保存测试通过报告,再提交审核。')
return
}
reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || ''
reviewSubmitReviewer.value = selectedSkill.value.reviewer || ''
reviewSubmitOpen.value = true
@@ -1323,6 +1428,10 @@ export default {
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
toast('当前规则版本尚未确认测试通过,不能提交审核。')
return
}
const version = normalizeText(reviewSubmitVersion.value)
const reviewer = normalizeText(reviewSubmitReviewer.value)
if (!version) {
@@ -1357,6 +1466,155 @@ export default {
}
}
function openRiskRuleTestDialog() {
if (!canOpenRiskRuleTest.value) {
if (!selectedSkill.value?.id) {
toast('规则详情还没有加载完成,请稍后再测试。')
}
return
}
riskRuleTestOpen.value = true
}
function closeRiskRuleTestDialog() {
riskRuleTestOpen.value = false
}
async function handleRiskRuleReportSaved(summary) {
if (selectedSkill.value) {
selectedSkill.value.latestTestSummary = summary
}
await refreshCurrentAssets()
if (selectedSkill.value?.id) {
await loadSelectedAssetDetail(selectedSkill.value.id)
}
}
function openDeleteRiskRuleDialog() {
if (!canDeleteRiskRule.value) {
return
}
riskRuleDeleteOpen.value = true
}
function closeDeleteRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRuleDeleteOpen.value = false
}
async function deleteSelectedRiskRule() {
if (!selectedSkill.value || !canDeleteRiskRule.value || detailBusy.value) {
return
}
actionState.value = 'delete-risk-rule'
try {
await deleteAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRuleDeleteOpen.value = false
const deletedName = selectedSkill.value.name
closeDetail()
await refreshCurrentAssets()
toast(`风险规则“${deletedName}”已删除。`)
} catch (error) {
toast(error?.message || '风险规则删除失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function openReturnRiskRuleDialog() {
if (!canReturnRiskRule.value) {
return
}
riskRuleReturnNote.value = ''
riskRuleReturnOpen.value = true
}
function closeReturnRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRuleReturnOpen.value = false
}
async function returnSelectedRiskRule() {
if (!selectedSkill.value || !canReturnRiskRule.value || detailBusy.value) {
return
}
const note = normalizeText(riskRuleReturnNote.value)
if (!note) {
toast('请填写回退原因。')
return
}
actionState.value = 'return-risk-rule'
try {
await returnRiskRuleAsset(selectedSkill.value.id, { note }, { actor: resolveActor() })
riskRuleReturnOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('风险规则已回退到草稿。')
} catch (error) {
toast(error?.message || '风险规则回退失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function openPublishRiskRuleDialog() {
if (!canPublishRiskRule.value) {
if (!riskRuleTestPassed.value) {
toast('请先确认测试报告通过,再发布上线。')
}
return
}
riskRulePublishOpen.value = true
}
function closePublishRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRulePublishOpen.value = false
}
async function publishSelectedRiskRule() {
if (!selectedSkill.value || !canPublishRiskRule.value || detailBusy.value) {
return
}
actionState.value = 'publish-risk-rule'
try {
await publishRiskRuleAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRulePublishOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('风险规则已发布上线。')
} catch (error) {
toast(error?.message || '风险规则发布失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function toggleSelectedRiskRuleEnabled() {
if (!selectedSkill.value || !canToggleRiskRuleEnabled.value || detailBusy.value) {
return
}
const assetId = selectedSkill.value.id
const nextEnabled = !selectedSkill.value.isEnabledValue
actionState.value = 'toggle-risk-rule-enabled'
try {
await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(assetId)
toast(nextEnabled ? '风险规则已启用。' : '风险规则已停用,不会进入业务扫描。')
} catch (error) {
toast(error?.message || '风险规则启用状态更新失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function activateSelectedRule() {
if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
return
@@ -1458,6 +1716,8 @@ export default {
showVersionColumn,
showMetricColumn,
showStatusColumn,
showOnlineColumn,
showEnabledColumn,
visibleSkills,
auditEmptyState,
loading,
@@ -1468,21 +1728,37 @@ export default {
selectedOwner,
selectedStatus,
selectedRiskScenario,
selectedOnlineState,
selectedEnabledState,
selectedDomainLabel,
selectedOwnerLabel,
selectedStatusLabel,
selectedRiskScenarioLabel,
selectedOnlineStateLabel,
selectedEnabledStateLabel,
showRiskScenarioFilter,
showStatusFilter,
showOnlineFilter,
showEnabledFilter,
domainOptions,
ownerOptions,
statusOptions: STATUS_OPTIONS,
riskScenarioOptions: RISK_SCENARIO_OPTIONS,
onlineStateOptions: ONLINE_STATE_OPTIONS,
enabledStateOptions: ENABLED_STATE_OPTIONS,
activeFilterPopover,
activeFilterTokens,
canManageSelected,
canEditSelected,
canCreateRiskRule,
canOpenRiskRuleTest,
canDeleteRiskRule,
canSubmitRiskRuleReview,
canReturnRiskRule,
canPublishRiskRule,
canToggleRiskRuleEnabled,
riskRuleTestPassed,
riskRuleInReview,
canSubmitReview,
hasReviewSubmitReviewers,
canReviewSelected,
@@ -1509,7 +1785,13 @@ export default {
riskRuleCreateOpen,
riskRuleCreateForm,
riskRuleCreateBusy,
riskRuleTestOpen,
riskRuleDeleteOpen,
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
showReviewNote,
spreadsheetUploadInput,
@@ -1549,6 +1831,19 @@ export default {
openSubmitReviewDialog,
closeSubmitReviewDialog,
submitSelectedRuleForReview,
openRiskRuleTestDialog,
closeRiskRuleTestDialog,
handleRiskRuleReportSaved,
openDeleteRiskRuleDialog,
closeDeleteRiskRuleDialog,
deleteSelectedRiskRule,
openReturnRiskRuleDialog,
closeReturnRiskRuleDialog,
returnSelectedRiskRule,
openPublishRiskRuleDialog,
closePublishRiskRuleDialog,
publishSelectedRiskRule,
toggleSelectedRiskRuleEnabled,
activateSelectedRule,
restoreSelectedVersion,
openVersionTimeline,

View File

@@ -7,6 +7,13 @@ export const RULE_TABLE_COLUMNS = {
metric: '修改人'
}
export const RISK_RULE_TABLE_COLUMNS = {
...RULE_TABLE_COLUMNS,
owner: '审核人',
metric: '发布者',
updatedAt: '发布时间'
}
export const TYPE_META = {
rules: {
assetType: 'rule',
@@ -89,8 +96,8 @@ export const TAB_META = {
typeLabel: '风险规则',
createButtonLabel: '新建风险规则',
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
searchPlaceholder: '搜索风险规则名称、编码或负责人',
tableColumns: RULE_TABLE_COLUMNS,
searchPlaceholder: '搜索风险规则名称、编码或审核人',
tableColumns: RISK_RULE_TABLE_COLUMNS,
showRuntimeColumn: false,
showVersionColumn: false,
showStatusColumn: false,
@@ -249,6 +256,18 @@ export const STATUS_OPTIONS = [
{ value: 'disabled', label: '已停用' }
]
export const ONLINE_STATE_OPTIONS = [
{ value: '', label: '全部上线状态' },
{ value: 'online', label: '已上线' },
{ value: 'offline', label: '未上线' }
]
export const ENABLED_STATE_OPTIONS = [
{ value: '', label: '全部启用状态' },
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已停用' }
]
export const EXPENSE_RULE_BLOCK_PATTERN = /```expense-rule\s*([\s\S]*?)\s*```/i
export const RULE_SPREADSHEET_BLOCK_PATTERN = /```rule-spreadsheet\s*([\s\S]*?)\s*```/i

View File

@@ -34,6 +34,7 @@ import {
export {
DETAIL_TITLES,
DOMAIN_LABELS,
ENABLED_STATE_OPTIONS,
EXPENSE_RULE_BLOCK_PATTERN,
JSON_RISK_DETAIL_MODE,
LEGACY_RISK_SCENARIO_KEYS,
@@ -43,6 +44,7 @@ export {
REVIEW_META,
RISK_SCENARIO_OPTIONS,
RISK_SCENARIO_VALUES,
RISK_RULE_TABLE_COLUMNS,
RULE_SPREADSHEET_BLOCK_PATTERN,
RULE_TABLE_COLUMNS,
RULE_TAB_TAG_ALIASES,
@@ -51,6 +53,7 @@ export {
SPREADSHEET_DETAIL_MODE,
STATUS_META,
STATUS_OPTIONS,
ONLINE_STATE_OPTIONS,
TAB_META,
TYPE_META,
VERSION_STATE_META
@@ -189,6 +192,17 @@ export function readConfigJson(value) {
return {}
}
export function resolveRiskRuleEnabled(source, rulePayload = null) {
const configJson = readConfigJson(source)
if (isPlainObject(rulePayload) && rulePayload.enabled === false) {
return false
}
if (source?.enabled === false || configJson.enabled === false) {
return false
}
return true
}
export function readRuleDocumentMeta(value) {
const configJson = readConfigJson(value)
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
@@ -417,6 +431,12 @@ export function buildRiskListSubtitle(text, maxLength = 42) {
export function applyRiskRuleJsonState(target, payload, apiPayload) {
const rulePayload = isPlainObject(payload) ? payload : {}
const metadata = rulePayload.metadata && typeof rulePayload.metadata === 'object'
? rulePayload.metadata
: {}
const apiConfig = apiPayload?.config_json && typeof apiPayload.config_json === 'object'
? apiPayload.config_json
: {}
const fullDescription =
resolveRiskRuleDescription(rulePayload) ||
normalizeText(apiPayload?.description) ||
@@ -427,6 +447,21 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher = apiPayload?.created_by || target.publisher || (apiPayload?.recent_versions && apiPayload.recent_versions[0]?.created_by) || '系统管理员'
let publishedAt = target.publishedAt || '-'
if (apiPayload?.recent_versions) {
const history = buildHistory(apiPayload.recent_versions, { ...target, config_json: payload })
const publishedVersionObj = history.find((item) => item.isPublished || item.lifecycleState === 'published')
publishedAt = publishedVersionObj ? publishedVersionObj.time : (apiPayload?.latest_review?.reviewed_at ? formatDateTime(apiPayload.latest_review.reviewed_at) : '-')
} else if (apiPayload?.latest_review?.reviewed_at) {
publishedAt = formatDateTime(apiPayload.latest_review.reviewed_at)
}
return {
...target,
riskRuleDescription: fullDescription,
@@ -444,6 +479,12 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
riskRuleFlowDiagramSvg:
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
riskRuleRequiresAttachment: Boolean(
rulePayload.requires_attachment ||
metadata.requires_attachment ||
apiConfig.requires_attachment ||
target.configJson?.requires_attachment
),
riskRuleSummary: {
name: apiPayload?.name || target.name,
evaluator: apiPayload?.evaluator || rulePayload.evaluator || '',
@@ -451,7 +492,13 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
inputs: apiPayload?.inputs || rulePayload.inputs || {},
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
},
riskRuleJsonText: JSON.stringify(rulePayload, null, 2)
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
isOnlineLabel,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
publisher,
publishedAt
}
}
@@ -810,6 +857,15 @@ export function buildListItem(asset) {
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
const isOnlineValue = asset.status === 'active'
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const publisher = isRiskRule
? isOnlineValue
? normalizeText(asset.published_by) || reviewer || modifiedBy || '系统管理员'
: '-'
: ''
const publishedAt = isRiskRule && isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-'
return {
id: asset.id,
@@ -826,8 +882,8 @@ export function buildListItem(asset) {
summary: listSubtitle,
listSubtitle,
category: resolveDomainLabel(asset.domain),
owner: asset.owner,
reviewer: asset.reviewer || '待分配',
owner: isRiskRule ? reviewer : asset.owner,
reviewer,
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
riskCategory: ruleScenarioCategory,
model: buildRowRuntime(asset, typeKey),
@@ -838,10 +894,18 @@ export function buildListItem(asset) {
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
hitRate: buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
hitRate: isRiskRule ? publisher : buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
publisher,
publishedAt,
isOnlineValue,
isOnlineLabel: isOnlineValue ? '是' : '否',
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
modifiedBy,
changeCount,
updatedAt: formatDateTime(asset.updated_at),
updatedAt: isRiskRule ? publishedAt : formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
domainValue: asset.domain
}
@@ -1218,6 +1282,7 @@ export function buildDetailViewModel(detail, runs) {
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(detail) : true
return {
id: detail.id,
@@ -1258,10 +1323,28 @@ export function buildDetailViewModel(detail, runs) {
riskRuleSeverityLabel: '中风险',
riskRuleCreatedAt: formatDateTime(detail.created_at),
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
isOnlineLabel: detail.status === 'active' ? '是' : '否',
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
publisher:
detail.status === 'active'
? normalizeText(detail.published_by) ||
detail.latest_review?.reviewer ||
detail.reviewer ||
(detail.recent_versions && detail.recent_versions[0]?.created_by) ||
'系统管理员'
: '-',
publishedAt:
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
(detail.latest_review?.reviewed_at ? formatDateTime(detail.latest_review.reviewed_at) : '-'),
riskRuleFields: [],
riskRuleFieldSummary: '未识别字段',
riskRuleFlow: resolveRiskRuleFlow({}, []),
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
riskRuleRequiresAttachment: Boolean(configJson.requires_attachment),
latestTestSummary: detail.latest_test_summary || detail.latestTestSummary || null,
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
ruleDocument,
scenarioList: typeKey === 'rules' && ruleScenarioCategory

View File

@@ -4,6 +4,18 @@ export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
{ value: 'ap', label: '应付' }
]
export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'meeting', label: '会务费' },
{ value: 'office', label: '办公用品费' },
{ value: 'training', label: '培训费' },
{ value: 'communication', label: '通讯费' },
{ value: 'welfare', label: '福利费' }
]
export const RISK_RULE_LEVEL_OPTIONS = [
{ value: 'medium', label: '中风险' },
{ value: 'high', label: '高风险' },
@@ -19,7 +31,9 @@ const RISK_LEVEL_LABELS = {
export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
expense_category: 'travel',
risk_level: 'medium',
requires_attachment: false,
natural_language: ''
}
}

View File

@@ -103,7 +103,25 @@ export function filterAuditAssets(assets = [], filters = {}) {
? item.riskCategory === filters.selectedRiskScenario
: true
: true
const matchesOnline = filters.showOnlineFilter
? filters.selectedOnlineState
? (filters.selectedOnlineState === 'online') === Boolean(item.isOnlineValue)
: true
: true
const matchesEnabled = filters.showEnabledFilter
? filters.selectedEnabledState
? (filters.selectedEnabledState === 'enabled') === Boolean(item.isEnabledValue)
: true
: true
return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario
return (
matchesKeyword &&
matchesDomain &&
matchesOwner &&
matchesStatus &&
matchesRiskScenario &&
matchesOnline &&
matchesEnabled
)
})
}