feat: 增强规则资产管理与审计页面运行时调试
后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险 规则生成模板执行和员工数据模型字段,知识库 RAG 增强本 地回退和文档提取能力,清理旧风险规则文件统一由生成引擎 管理,前端审计页面增加运行时调试面板和规则资产编辑交互, 补充单元测试覆盖。
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
788
web/src/views/DocumentsCenterView.vue
Normal file
788
web/src/views/DocumentsCenterView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user