refactor(audit): reuse list shells and split models

This commit is contained in:
caoxiaozhu
2026-05-29 10:13:49 +08:00
parent 99e90798d2
commit 64cc76c970
12 changed files with 1500 additions and 1468 deletions

View File

@@ -1,19 +1,21 @@
<template> <template>
<article class="skill-list panel"> <EnterpriseListPage
<nav class="status-tabs" aria-label="能力类型"> variant="skill-list"
<button :tabs="shellTabs"
v-for="tab in tabs" :active-tab="activeType"
:key="tab.id" tabs-label="能力类型"
type="button" :loading="loading"
:class="{ active: activeType === tab.id }" :error="errorMessage"
@click="emit('update:activeType', tab.id)" :empty="!visibleSkills.length"
:empty-state="auditEmptyState"
:loading-title="`${activeTabLabel}资产同步中`"
:loading-message="`正在加载${activeTabLabel}资产`"
loading-icon="mdi mdi-view-list-outline"
:hint="hintText"
@update:active-tab="emit('update:activeType', $event)"
@empty-action="emit('empty-action')"
> >
{{ tab.label }} <template #filters>
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<label class="search-filter"> <label class="search-filter">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<input <input
@@ -120,9 +122,9 @@
@close="emit('close-filter-popover')" @close="emit('close-filter-popover')"
@select="selectFilter('status', $event)" @select="selectFilter('status', $event)"
/> />
</div> </template>
<div class="toolbar-actions"> <template #actions>
<button <button
v-if="activeFilterTokens.length" v-if="activeFilterTokens.length"
class="ghost-filter-btn" class="ghost-filter-btn"
@@ -142,50 +144,23 @@
<i class="mdi mdi-plus"></i> <i class="mdi mdi-plus"></i>
<span>{{ createButtonLabel }}</span> <span>{{ createButtonLabel }}</span>
</button> </button>
</div> </template>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> {{ hintText }}</p>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip"> <div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip"> <span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }} {{ token }}
</span> </span>
</div> </div>
</template>
<div <template #error>
class="table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
:title="`${activeTabLabel}资产同步中`"
:message="`正在加载${activeTabLabel}资产`"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p> <p>{{ errorMessage }}</p>
</div> </template>
<TableEmptyState <template #table>
v-else-if="!visibleSkills.length" <table>
:eyebrow="auditEmptyState.eyebrow"
:title="auditEmptyState.title"
:description="auditEmptyState.desc"
:icon="auditEmptyState.icon"
:action-label="auditEmptyState.actionLabel"
:action-icon="auditEmptyState.actionIcon"
:tone="auditEmptyState.tone"
:art-label="auditEmptyState.artLabel"
:tips="auditEmptyState.tips"
@action="emit('empty-action')"
/>
<table v-else>
<thead> <thead>
<tr> <tr>
<th>{{ tableColumns.name }}</th> <th>{{ tableColumns.name }}</th>
@@ -245,24 +220,27 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </template>
<template #footer>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot"> <footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span> <span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer> </footer>
</article> </template>
</EnterpriseListPage>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue' import AuditPickerFilter from './AuditPickerFilter.vue'
import TableEmptyState from '../shared/TableEmptyState.vue' import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
defineOptions({ defineOptions({
name: 'AuditAssetList' name: 'AuditAssetList'
}) })
defineProps({ const props = defineProps({
tabs: { type: Array, default: () => [] }, tabs: { type: Array, default: () => [] },
activeType: { type: String, default: '' }, activeType: { type: String, default: '' },
activeTabLabel: { type: String, default: '' }, activeTabLabel: { type: String, default: '' },
@@ -325,6 +303,13 @@ const emit = defineEmits([
'open-asset-detail' 'open-asset-detail'
]) ])
const shellTabs = computed(() =>
props.tabs.map((tab) => ({
value: tab.id,
label: tab.label
}))
)
function selectFilter(type, value) { function selectFilter(type, value) {
emit('select-filter', type, value) emit('select-filter', type, value)
} }

View File

@@ -1,7 +1,17 @@
<template> <template>
<section class="digital-employee-list-panel"> <EnterpriseListPage
<div class="list-toolbar"> variant="digital-employee-list-panel"
<div class="filter-set"> :panel="false"
:loading="loading"
:error="errorMessage"
:empty="!visibleEmployees.length"
:empty-state="emptyState"
loading-title="数字员工资产同步中"
loading-message="正在加载数字员工资产"
loading-icon="mdi mdi-view-list-outline"
hint="归集后台自动执行的数字员工技能,可查看技能内容、执行计划、启动状态和最近版本。"
>
<template #filters>
<label class="search-filter"> <label class="search-filter">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<input <input
@@ -50,9 +60,9 @@
@close="emit('close-filter-popover')" @close="emit('close-filter-popover')"
@select="selectFilter('executionMode', $event)" @select="selectFilter('executionMode', $event)"
/> />
</div> </template>
<div class="toolbar-actions"> <template #actions>
<button <button
v-if="keyword || activeFilterTokens.length" v-if="keyword || activeFilterTokens.length"
class="ghost-filter-btn" class="ghost-filter-btn"
@@ -71,50 +81,23 @@
<i class="mdi mdi-refresh"></i> <i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span> <span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button> </button>
</div> </template>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
归集后台自动执行的数字员工技能可查看技能内容执行计划启动状态和最近版本
</p>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip"> <div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip"> <span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }} {{ token }}
</span> </span>
</div> </div>
</template>
<div <template #error>
class="table-wrap digital-table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleEmployees.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
title="数字员工资产同步中"
message="正在加载数字员工资产"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p> <p>{{ errorMessage }}</p>
</div> </template>
<TableEmptyState <template #table>
v-else-if="!visibleEmployees.length" <table class="digital-employees-table">
eyebrow="数字员工"
title="暂无匹配的数字员工"
description="当前没有符合搜索条件的后台执行技能。"
icon="mdi mdi-account-cog-outline"
tone="theme"
art-label="STAFF"
:tips="['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']"
/>
<table v-else class="digital-employees-table">
<colgroup> <colgroup>
<col class="col-skill"> <col class="col-skill">
<col class="col-skill-type"> <col class="col-skill-type">
@@ -164,8 +147,9 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </template>
<template #footer>
<footer v-if="!loading && !errorMessage && visibleEmployees.length" class="list-foot digital-employee-pagination"> <footer v-if="!loading && !errorMessage && visibleEmployees.length" class="list-foot digital-employee-pagination">
<span class="page-summary"> {{ visibleEmployees.length }} 目前第 {{ currentPage }} / {{ totalPages }} </span> <span class="page-summary"> {{ visibleEmployees.length }} 目前第 {{ currentPage }} / {{ totalPages }} </span>
<div class="pager" aria-label="员工技能分页"> <div class="pager" aria-label="员工技能分页">
@@ -187,15 +171,15 @@
</button> </button>
</div> </div>
</footer> </footer>
</section> </template>
</EnterpriseListPage>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue' import AuditPickerFilter from './AuditPickerFilter.vue'
import TableEmptyState from '../shared/TableEmptyState.vue' import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
defineOptions({ defineOptions({
name: 'DigitalEmployeeListPanel' name: 'DigitalEmployeeListPanel'
@@ -244,6 +228,15 @@ const pageNumbers = computed(() => {
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6)) const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index) return Array.from({ length: 7 }, (_, index) => start + index)
}) })
const emptyState = {
eyebrow: '数字员工',
title: '暂无匹配的数字员工',
description: '当前没有符合搜索条件的后台执行技能。',
icon: 'mdi mdi-account-cog-outline',
tone: 'theme',
artLabel: 'STAFF',
tips: ['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']
}
watch( watch(
() => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode], () => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode],
@@ -279,7 +272,7 @@ function selectFilter(type, value) {
overflow: hidden; overflow: hidden;
} }
.digital-employee-list-panel .digital-table-wrap { .digital-employee-list-panel :deep(.table-wrap) {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
} }
@@ -302,7 +295,7 @@ function selectFilter(type, value) {
gap: 6px; gap: 6px;
padding: 4px; padding: 4px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 12px; border-radius: 4px;
background: #f8fafc; background: #f8fafc;
} }
@@ -310,7 +303,7 @@ function selectFilter(type, value) {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 0; border: 0;
border-radius: 9px; border-radius: 4px;
background: transparent; background: transparent;
color: #334155; color: #334155;
font-size: 14px; font-size: 14px;
@@ -321,7 +314,7 @@ function selectFilter(type, value) {
.digital-employee-list-panel .pager button:hover:not(.active):not(:disabled) { .digital-employee-list-panel .pager button:hover:not(.active):not(:disabled) {
background: #fff; background: #fff;
color: var(--theme-primary-active); color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08); box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
} }
.digital-employee-list-panel .pager button.active { .digital-employee-list-panel .pager button.active {

View File

@@ -1,17 +1,7 @@
<template> <template>
<article class="enterprise-detail-page" :class="variant"> <article class="enterprise-detail-page" :class="variant">
<div class="detail-scroll"> <div class="detail-scroll">
<TableLoadingState <section v-if="error" class="detail-inline-state panel error">
v-if="loading"
class="detail-loading-state panel"
variant="panel"
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
:show-skeleton="false"
/>
<section v-else-if="error" class="detail-inline-state panel error">
<slot name="error" :error="error"> <slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<div> <div>
@@ -21,6 +11,16 @@
</slot> </slot>
</section> </section>
<TableLoadingState
v-else-if="loading"
class="detail-loading-state panel"
variant="panel"
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
:show-skeleton="false"
/>
<template v-else> <template v-else>
<section v-if="$slots.hero" class="detail-hero panel"> <section v-if="$slots.hero" class="detail-hero panel">
<slot name="hero"></slot> <slot name="hero"></slot>
@@ -40,7 +40,7 @@
</template> </template>
</div> </div>
<footer v-if="backLabel || $slots.actions" class="detail-actions"> <footer v-if="backLabel || $slots.actions" class="detail-actions" :class="actionsClass">
<button v-if="backLabel" class="back-action" type="button" @click="emit('back')"> <button v-if="backLabel" class="back-action" type="button" @click="emit('back')">
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
<span>{{ backLabel }}</span> <span>{{ backLabel }}</span>
@@ -57,6 +57,7 @@
import TableLoadingState from './TableLoadingState.vue' import TableLoadingState from './TableLoadingState.vue'
defineProps({ defineProps({
actionsClass: { type: [String, Array, Object], default: '' },
backLabel: { type: String, default: '' }, backLabel: { type: String, default: '' },
error: { type: String, default: '' }, error: { type: String, default: '' },
errorTitle: { type: String, default: '详情加载失败' }, errorTitle: { type: String, default: '详情加载失败' },
@@ -64,7 +65,7 @@ defineProps({
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' }, loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' }, loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '正在加载详情' }, loadingTitle: { type: String, default: '正在加载详情' },
variant: { type: String, default: '' } variant: { type: [String, Array, Object], default: '' }
}) })
const emit = defineEmits(['back']) const emit = defineEmits(['back'])

View File

@@ -1,5 +1,5 @@
<template> <template>
<article class="enterprise-list-page panel" :class="variant"> <article class="enterprise-list-page" :class="[variant, { panel }]">
<slot name="before"></slot> <slot name="before"></slot>
<nav v-if="hasTabs" class="status-tabs" :aria-label="tabsLabel"> <nav v-if="hasTabs" class="status-tabs" :aria-label="tabsLabel">
@@ -51,12 +51,15 @@
</slot> </slot>
</p> </p>
<slot name="meta"></slot>
<div class="table-wrap" :class="{ 'is-empty': empty, 'has-error': Boolean(error) }"> <div class="table-wrap" :class="{ 'is-empty': empty, 'has-error': Boolean(error) }">
<div v-if="loading" class="table-state"> <div v-if="loading" class="table-state">
<TableLoadingState <TableLoadingState
:title="loadingTitle" :title="loadingTitle"
:message="loadingMessage" :message="loadingMessage"
:icon="loadingIcon" :icon="loadingIcon"
floating
/> />
</div> </div>
@@ -135,6 +138,7 @@ const props = defineProps({
type: Array, type: Array,
default: () => [] default: () => []
}, },
panel: { type: Boolean, default: true },
pages: { pages: {
type: Array, type: Array,
default: () => [] default: () => []

View File

@@ -1,47 +1,29 @@
<template> <template>
<section class="digital-employees-view skill-center"> <section class="digital-employees-view skill-center">
<Transition name="skill-view" mode="out-in"> <Transition name="skill-view" mode="out-in">
<article <EnterpriseDetailPage
v-if="selectedEmployee" v-if="selectedEmployee"
key="detail" key="detail"
class="skill-detail digital-employee-detail json-risk-skill-detail" variant="skill-detail digital-employee-detail json-risk-skill-detail"
actions-class="digital-employee-detail-actions"
:error="detailError"
error-title="数字员工详情加载失败"
:loading="detailLoading && selectedEmployee.loading"
loading-title="正在加载数字员工详情"
loading-message="列表数据已就绪正在补充 Skills 源文件和执行配置"
loading-icon="mdi mdi-account-cog-outline"
back-label="返回数字员工列表"
@back="closeEmployeeDetail"
> >
<div class="detail-scroll">
<section v-if="detailError" class="detail-inline-state panel error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>数字员工详情加载失败</strong>
<p>{{ detailError }}</p>
</div>
</section>
<TableLoadingState
v-else-if="detailLoading && selectedEmployee.loading"
class="detail-loading-state panel"
variant="panel"
title="正在加载数字员工详情"
message="列表数据已就绪,正在补充 Skills 源文件和执行配置"
icon="mdi mdi-account-cog-outline"
:show-skeleton="false"
/>
<AuditDigitalEmployeeDetail <AuditDigitalEmployeeDetail
v-else
:selected-skill="selectedEmployee" :selected-skill="selectedEmployee"
:can-edit="canEditDigitalEmployeeSource" :can-edit="canEditDigitalEmployeeSource"
:detail-busy="detailBusy" :detail-busy="detailBusy"
:action-state="actionState" :action-state="actionState"
@save-source="saveDigitalEmployeeSource" @save-source="saveDigitalEmployeeSource"
/> />
</div>
<footer class="detail-actions digital-employee-detail-actions"> <template #actions>
<button class="back-action" type="button" @click="closeEmployeeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回数字员工列表</span>
</button>
<div class="detail-action-group">
<button <button
class="minor-action enable-action" class="minor-action enable-action"
:class="{ 'is-on': selectedEmployee.statusValue === 'active' }" :class="{ 'is-on': selectedEmployee.statusValue === 'active' }"
@@ -70,9 +52,8 @@
<i class="mdi mdi-play-circle-outline"></i> <i class="mdi mdi-play-circle-outline"></i>
<span>{{ actionBusy(selectedEmployee.id, 'run-digital-now') ? '运行中...' : '立即运行' }}</span> <span>{{ actionBusy(selectedEmployee.id, 'run-digital-now') ? '运行中...' : '立即运行' }}</span>
</button> </button>
</div> </template>
</footer> </EnterpriseDetailPage>
</article>
<article <article
v-else v-else
@@ -98,168 +79,29 @@
</nav> </nav>
<template v-if="activeSection === 'skills'"> <template v-if="activeSection === 'skills'">
<div class="list-toolbar"> <DigitalEmployeeListPanel
<div class="filter-set"> v-model:keyword="keyword"
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input v-model="keyword" type="search" placeholder="搜索技能名称、编号、执行计划或维护人" />
</label>
<AuditPickerFilter
id="status"
title="选择资产状态"
close-label="关闭资产状态选择"
:active-filter-popover="activeFilterPopover" :active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel" :selected-status="selectedStatus"
:options="statusOptions" :selected-status-label="selectedStatusLabel"
:selected-value="selectedStatus" :status-options="statusOptions"
@toggle="toggleFilterPopover" :selected-enabled-state="selectedEnabledState"
@close="closeFilterPopover" :selected-enabled-label="selectedEnabledLabel"
@select="selectFilter('status', $event)" :enabled-state-options="enabledStateOptions"
:selected-execution-mode="selectedExecutionMode"
:selected-execution-mode-label="selectedExecutionModeLabel"
:execution-mode-options="executionModeOptions"
:active-filter-tokens="activeFilterTokens"
:loading="loading"
:error-message="errorMessage"
:visible-employees="visibleEmployees"
@toggle-filter-popover="toggleFilterPopover"
@close-filter-popover="closeFilterPopover"
@select-filter="selectFilter"
@reset-filters="resetFilters"
@load-employees="loadEmployees"
@open-employee-detail="openEmployeeDetail"
/> />
<AuditPickerFilter
id="enabled"
title="选择启动状态"
close-label="关闭启动状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
id="executionMode"
title="选择执行方式"
close-label="关闭执行方式选择"
:active-filter-popover="activeFilterPopover"
:label="selectedExecutionModeLabel"
:options="executionModeOptions"
:selected-value="selectedExecutionMode"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('executionMode', $event)"
/>
</div>
<div class="toolbar-actions">
<button
v-if="keyword || activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="resetFilters"
>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
class="create-btn digital-refresh-action"
type="button"
:disabled="loading"
@click="loadEmployees"
>
<i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
集中查看后台自动执行的技能执行计划和运行状态
</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
<div
class="table-wrap digital-table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleEmployees.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
title="数字员工同步中"
message="正在读取后台自动执行技能列表"
icon="mdi mdi-account-cog-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>数字员工加载失败</strong>
<p>{{ errorMessage }}</p>
</div>
<TableEmptyState
v-else-if="!visibleEmployees.length"
eyebrow="数字员工"
title="暂无匹配的数字员工"
description="当前没有符合搜索条件的后台执行技能。"
icon="mdi mdi-account-cog-outline"
tone="theme"
art-label="STAFF"
:tips="['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']"
/>
<table v-else class="digital-employees-table">
<colgroup>
<col class="col-skill">
<col class="col-schedule">
<col class="col-mode">
<col class="col-skill-type">
<col class="col-status">
<col class="col-enabled">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>技能名称</th>
<th>执行计划</th>
<th>触发方式</th>
<th>技能类型</th>
<th>资产状态</th>
<th>启动状态</th>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="employee in visibleEmployees"
:key="employee.id"
@click="openEmployeeDetail(employee)"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="employee.badgeTone">{{ employee.short }}</span>
<div>
<strong>{{ employee.name }}</strong>
<span class="skill-list-subtitle">{{ employee.code }}</span>
</div>
</div>
</td>
<td><span class="scope-pill">{{ employee.scope }}</span></td>
<td>{{ employee.executionMode }}</td>
<td><span class="scope-pill skill-type-pill">{{ employee.skillCategory }}</span></td>
<td>
<span :class="['status-pill', employee.statusTone]">{{ employee.status }}</span>
</td>
<td><span :class="['status-pill', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td>{{ employee.updatedAt || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && !errorMessage && visibleEmployees.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleEmployees.length }} 条数字员工</span>
</footer>
</template> </template>
<DigitalEmployeeWorkRecords <DigitalEmployeeWorkRecords
@@ -290,11 +132,10 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue' import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue'
import AuditPickerFilter from '../components/audit/AuditPickerFilter.vue' import DigitalEmployeeListPanel from '../components/audit/DigitalEmployeeListPanel.vue'
import DigitalEmployeeScheduleDialog from '../components/audit/DigitalEmployeeScheduleDialog.vue' import DigitalEmployeeScheduleDialog from '../components/audit/DigitalEmployeeScheduleDialog.vue'
import DigitalEmployeeWorkRecords from '../components/audit/DigitalEmployeeWorkRecords.vue' import DigitalEmployeeWorkRecords from '../components/audit/DigitalEmployeeWorkRecords.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue' import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
import { useToast } from '../composables/useToast.js' import { useToast } from '../composables/useToast.js'
import { import {

View File

@@ -0,0 +1,17 @@
export function normalizeText(value) {
return String(value || '').trim()
}
export function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function readConfigJson(value) {
if (isPlainObject(value?.configJson)) {
return value.configJson
}
if (isPlainObject(value?.config_json)) {
return value.config_json
}
return {}
}

View File

@@ -0,0 +1,128 @@
import {
DOMAIN_LABELS,
REVIEW_META,
SCENARIO_LABELS,
STATUS_META
} from './auditViewMetadata.js'
import { normalizeText } from './auditViewDataUtils.js'
export function makeShort(value) {
const text = normalizeText(value).replace(/\s+/g, '')
if (!text) {
return 'AG'
}
return text.slice(0, 2).toUpperCase()
}
export function formatDateTime(value) {
if (!value) {
return '未记录'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value)
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
.format(date)
.replace(/\//g, '-')
}
export function resolveDomainLabel(value) {
return DOMAIN_LABELS[value] || normalizeText(value) || '未分类'
}
export function resolveStatusMeta(value) {
return STATUS_META[value] || { label: normalizeText(value) || '未知状态', tone: 'draft' }
}
export function resolveReviewMeta(value) {
return REVIEW_META[value] || { label: '暂无审核', tone: 'draft' }
}
export function resolveTimelineEventMeta(value) {
return {
created: { label: '创建工作稿', icon: 'mdi mdi-file-document-edit-outline', tone: 'draft' },
submitted: { label: '提交审核', icon: 'mdi mdi-send-outline', tone: 'warning' },
approved: { label: '审核通过', icon: 'mdi mdi-check-decagram-outline', tone: 'success' },
rejected: { label: '审核驳回', icon: 'mdi mdi-close-octagon-outline', tone: 'danger' },
published: { label: '正式上线', icon: 'mdi mdi-rocket-launch-outline', tone: 'success' },
restored: { label: '恢复生成工作稿', icon: 'mdi mdi-history', tone: 'info' }
}[normalizeText(value)] || { label: normalizeText(value) || '版本事件', icon: 'mdi mdi-circle-medium', tone: 'draft' }
}
export function resolveDiffChangeMeta(value) {
return {
added: { label: '新增', tone: 'success' },
removed: { label: '删除', tone: 'danger' },
modified: { label: '修改', tone: 'warning' }
}[normalizeText(value)] || { label: normalizeText(value) || '变化', tone: 'draft' }
}
export function formatScenarioList(items) {
if (!Array.isArray(items) || !items.length) {
return '未配置场景'
}
return items
.map((item) => SCENARIO_LABELS[item] || item)
.filter(Boolean)
.join(' / ')
}
export function resolveTypeKey(assetType) {
if (assetType === 'rule') {
return 'rules'
}
if (assetType === 'mcp') {
return 'mcp'
}
return ''
}
export function formatSeverity(value) {
const severity = normalizeText(value).toLowerCase()
if (severity === 'high') {
return '高风险'
}
if (severity === 'medium') {
return '中风险'
}
if (severity === 'low') {
return '低风险'
}
return '未配置'
}
export function formatInputSummary(items) {
if (!Array.isArray(items) || !items.length) {
return '无输入'
}
return `${items.length} 项输入`
}
export function formatOutputSummary(items) {
if (!Array.isArray(items) || !items.length) {
return '无输出'
}
return `${items.length} 项输出`
}
export function formatSpreadsheetChangeSummary(summary) {
const normalized = normalizeText(summary)
return (
normalized
.replace(/^(ONLYOFFICE\s*)?在线编辑[:]\s*/i, '')
.replace(/^ONLYOFFICE\s*在线编辑保存[。.]?\s*/i, '')
.replace(/^保存表格[:]\s*/i, '')
.trim() || '表格内容已保存。'
)
}

View File

@@ -0,0 +1,205 @@
import {
resolveRiskRuleScoreLabel,
resolveRiskRuleScoreLevel,
resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js'
import { normalizeText } from './auditViewDataUtils.js'
import {
formatDateTime,
formatScenarioList,
formatSeverity,
makeShort,
resolveDomainLabel,
resolveStatusMeta,
resolveTypeKey
} from './auditViewFormatters.js'
import {
buildRiskListSubtitle,
isJsonRiskRuleSource,
isSpreadsheetRuleSource,
readRuleDocumentMeta,
resolveRuleScenarioCategory,
resolveRuleScenarioList,
resolveTabId,
resolveTabMeta
} from './auditViewRuleClassifier.js'
import {
resolveRiskRuleBusinessStage,
resolveRiskRuleEnabled,
resolveRiskRuleOnlineMeta
} from './auditViewRiskRuleState.js'
export function findLatestMcpCall(runs, assetCode) {
const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '')
for (const run of runs) {
for (const toolCall of run.tool_calls || []) {
const toolName = normalizeText(toolCall.tool_name)
if (
toolName === expectedToolName ||
toolName.endsWith(expectedToolName) ||
expectedToolName.endsWith(toolName)
) {
return {
run,
toolCall
}
}
}
}
return null
}
export function buildRowRuntime(asset, typeKey) {
if (typeKey === 'rules') {
return formatSeverity(asset.config_json?.severity)
}
if (typeKey === 'mcp') {
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
}
return ''
}
export function buildRowMetric(asset, typeKey) {
if (typeKey === 'rules') {
return normalizeText(asset.modified_by) || '未记录'
}
if (typeKey === 'mcp') {
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
}
return ''
}
export function buildListItem(asset) {
const typeKey = resolveTypeKey(asset.asset_type)
const tabId = resolveTabId(asset, typeKey)
if (!tabId) {
return null
}
const tabMeta = resolveTabMeta(tabId, typeKey)
const statusMeta = resolveStatusMeta(asset.status)
const workingVersion = asset.working_version || asset.current_version || '-'
const changeCount =
typeof asset.change_count === 'number'
? asset.change_count
: Array.isArray(asset.recent_versions)
? Math.max(asset.recent_versions.length - 1, 0)
: 0
const modifiedBy =
normalizeText(asset.modified_by) ||
normalizeText(
Array.isArray(asset.recent_versions)
? asset.recent_versions.find((item) => item.version === workingVersion)?.created_by
: ''
)
const isRiskRule = tabId === 'riskRules'
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
const ruleDocument = readRuleDocumentMeta(asset)
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : ''
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
const onlineMeta = resolveRiskRuleOnlineMeta(asset.status)
const isOnlineValue = onlineMeta.online
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const creator =
normalizeText(asset.owner) ||
normalizeText(asset.config_json?.generation_request?.actor) ||
modifiedBy ||
'未知'
const publisher = isRiskRule ? creator : ''
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
const businessStage = usesJsonRiskRule
? resolveRiskRuleBusinessStage(asset)
: { value: '', label: '' }
const ruleScenarioList = typeKey === 'rules' ? resolveRuleScenarioList(asset, tabId) : []
const riskScoreLevel = usesJsonRiskRule
? resolveRiskRuleScoreLevel(asset.config_json, asset.config_json)
: ''
const riskLevelValue = usesJsonRiskRule
? riskScoreLevel || resolveRiskRuleSeverity(asset.config_json)
: ''
const riskLevelLabel = usesJsonRiskRule
? riskScoreLevel
? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json)
: resolveRiskRuleSeverityLabel(asset.config_json)
: ''
const displayName = asset.name
const displayCode = asset.code
const displaySummary = listSubtitle
const displayOwner = isRiskRule ? creator : asset.owner
const displayReviewer = reviewer
const displayCategory = resolveDomainLabel(asset.domain)
const displayScope = typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json)
const displayEnabledValue = isEnabledValue
const displayEnabledLabel = isEnabledValue ? '是' : '否'
const displayEnabledTone = isEnabledValue ? 'success' : 'disabled'
const searchText = [
displayName,
displayCode,
displaySummary,
displayOwner,
displayScope,
riskLevelLabel
]
.map((value) => normalizeText(value).toLowerCase())
.filter(Boolean)
.join(' ')
return {
id: asset.id,
tabId,
type: typeKey,
isPreviewMock: Boolean(asset.isPreviewMock),
usesSpreadsheetRule,
usesJsonRiskRule,
ruleDocument,
typeLabel: tabMeta.typeLabel,
short: makeShort(displayName),
name: displayName,
code: displayCode,
rawCode: asset.code,
summary: displaySummary,
listSubtitle: displaySummary,
category: displayCategory,
owner: displayOwner,
reviewer: displayReviewer,
scope: displayScope,
riskCategory: ruleScenarioCategory,
scenarioList: ruleScenarioList,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
riskLevelValue,
riskLevelLabel,
riskLevelTone: riskLevelValue,
model: buildRowRuntime(asset, typeKey),
version: workingVersion,
versionDisplay: typeKey === 'rules' ? `${changeCount}` : workingVersion,
publishedVersion: asset.published_version || '-',
workingVersion,
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
hitRate: isRiskRule ? publisher : buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
creator,
publisher,
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
isOnlineValue,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue: displayEnabledValue,
isEnabledLabel: displayEnabledLabel,
isEnabledTone: displayEnabledTone,
modifiedBy,
changeCount,
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
domainValue: asset.domain,
searchText
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import {
buildRiskRuleFieldSummary,
formatRiskRuleAge,
resolveRiskRuleBusinessDescription,
resolveRiskRuleCreatedAt,
resolveRiskRuleFields,
resolveRiskRuleFlow,
resolveRiskRuleFlowDiagramSvg,
resolveRiskRuleScore,
resolveRiskRuleScoreDetail,
resolveRiskRuleScoreLabel,
resolveRiskRuleScoreLevel,
resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js'
import {
isPlainObject,
normalizeText,
readConfigJson
} from './auditViewDataUtils.js'
import { formatDateTime } from './auditViewFormatters.js'
import {
resolveRiskRuleCategory,
resolveRiskRuleDescription,
resolveRiskRuleSourceRef
} from './auditViewRuleClassifier.js'
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
}
const LAST_OPERATION_LABELS = {
generate: '开始生成',
create: '创建',
test: '测试',
online: '上线',
offline: '下线',
delete: '删除',
update: '更新'
}
const RISK_RULE_BUSINESS_STAGE_LABELS = {
expense_application: '费用申请',
reimbursement: '费用报销'
}
export function resolveRiskRuleBusinessStage(source, rulePayload = null) {
const configJson = readConfigJson(source)
const metadata = rulePayload && typeof rulePayload === 'object' ? rulePayload.metadata || {} : {}
const stage =
normalizeText(configJson.business_stage) ||
normalizeText(metadata.business_stage) ||
normalizeText(rulePayload?.business_stage)
const label =
normalizeText(configJson.business_stage_label) ||
normalizeText(metadata.business_stage_label) ||
RISK_RULE_BUSINESS_STAGE_LABELS[stage]
return {
value: stage || 'reimbursement',
label: label || '费用报销'
}
}
export function resolveRiskRuleOnlineMeta(statusValue) {
if (statusValue === 'active') {
return { label: '已上线', tone: 'success', online: true }
}
if (statusValue === 'disabled') {
return { label: '已下线', tone: 'disabled', online: false }
}
if (statusValue === 'generating') {
return { label: '生成中', tone: 'info', online: false }
}
if (statusValue === 'failed') {
return { label: '生成失败', tone: 'danger', online: false }
}
return { label: '待上线', tone: 'draft', online: false }
}
function resolveLastOperationLabel(source, fallback = {}) {
const configJson = readConfigJson(source)
const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {}
const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create'
const actor = normalizeText(operation.actor) || normalizeText(fallback.actor) || '系统'
const at = normalizeText(operation.at) || normalizeText(fallback.at)
const actionLabel = LAST_OPERATION_LABELS[action] || action
const timeLabel = formatDateTime(at)
return timeLabel && timeLabel !== '-' ? `${actionLabel}${actor} · ${timeLabel}` : `${actionLabel}${actor}`
}
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) ||
normalizeText(target.riskRuleDescription)
const riskCategory =
normalizeText(metadata.expense_category_label) ||
normalizeText(apiConfig.expense_category_label) ||
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
const businessStage = resolveRiskRuleBusinessStage(target, rulePayload)
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const onlineMeta = resolveRiskRuleOnlineMeta(statusValue)
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher =
target.creator ||
normalizeText(apiPayload?.owner) ||
normalizeText(metadata.created_by) ||
normalizeText(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,
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
riskCategory,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleSeverityLabel: riskRuleScoreLevel
? resolveRiskRuleScoreLabel(rulePayload, apiConfig)
: resolveRiskRuleSeverityLabel(rulePayload),
riskRuleScore: resolveRiskRuleScore(rulePayload, apiConfig),
riskRuleScoreLabel: resolveRiskRuleScoreLabel(rulePayload, apiConfig),
riskRuleScoreLevel: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleScoreDetail: resolveRiskRuleScoreDetail(rulePayload, apiConfig),
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
riskRuleFields,
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
riskRuleFlowDiagramSvg: resolveRiskRuleFlowDiagramSvg({
...rulePayload,
flow_diagram_svg: normalizeText(apiPayload?.flow_diagram_svg) || rulePayload?.flow_diagram_svg
}),
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 || '',
ontologySignal: apiPayload?.ontology_signal || rulePayload.ontology_signal || '',
inputs: apiPayload?.inputs || rulePayload.inputs || {},
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
},
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
isOnlineValue: onlineMeta.online,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
lastOperationLabel: resolveLastOperationLabel(target, {
actor: publisher,
at: riskRuleCreatedAt
}),
publisher,
publishedAt
}
}

View File

@@ -0,0 +1,328 @@
import {
JSON_RISK_DETAIL_MODE,
LEGACY_RISK_SCENARIO_KEYS,
RISK_SCENARIO_VALUES,
RULE_TAB_TAG_ALIASES,
SPREADSHEET_DETAIL_MODE,
TAB_META,
TYPE_META
} from './auditViewMetadata.js'
import {
isPlainObject,
normalizeText,
readConfigJson
} from './auditViewDataUtils.js'
import { formatScenarioList } from './auditViewFormatters.js'
const EXPENSE_TYPE_SCENARIO_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '业务招待费',
meeting: '会务费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通信费',
welfare: '福利费'
}
export function readRuleDocumentMeta(value) {
const configJson = readConfigJson(value)
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
}
export function isSpreadsheetRuleSource(value) {
const configJson = readConfigJson(value)
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE
}
export function isJsonRiskRuleSource(value) {
const configJson = readConfigJson(value)
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === JSON_RISK_DETAIL_MODE
}
export function normalizeRuleTagValue(value) {
return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '')
}
export function collectRuleTagValues(source) {
const configJson = readConfigJson(source)
const rawValues = [
configJson.tag,
configJson.rule_tag,
...(Array.isArray(configJson.tags) ? configJson.tags : []),
...(Array.isArray(configJson.rule_tags) ? configJson.rule_tags : [])
]
return rawValues.map((item) => normalizeText(item)).filter(Boolean)
}
export function resolveRuleTabId(source) {
const code = normalizeText(source?.code || '').toLowerCase()
if (code.startsWith('risk.')) {
return 'riskRules'
}
if (isJsonRiskRuleSource(source)) {
return 'riskRules'
}
const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item))
if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) {
return 'riskRules'
}
if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.financialRules.has(item))) {
return 'financialRules'
}
return ''
}
export function resolveTabId(source, typeKey) {
if (typeKey === 'rules') {
return resolveRuleTabId(source)
}
return typeKey
}
export function resolveTabMeta(tabId, typeKey) {
if (TAB_META[tabId]) {
return TAB_META[tabId]
}
if (typeKey === 'rules') {
return {
...TYPE_META.rules,
typeKey: 'rules',
badgeTone: 'primary'
}
}
return TAB_META[typeKey]
}
export function resolveRiskRuleDescription(payload) {
if (!isPlainObject(payload)) {
return ''
}
return normalizeText(payload.description)
}
export function resolveRiskRuleSourceRef(payload) {
if (!isPlainObject(payload)) {
return ''
}
const metadata = isPlainObject(payload.metadata) ? payload.metadata : {}
return normalizeText(metadata.source_ref)
}
export function inferRiskCategoryFromCode(code) {
const normalized = normalizeText(code).toLowerCase()
if (normalized.startsWith('risk.travel.')) {
return '差旅'
}
if (normalized.startsWith('risk.invoice.')) {
return '发票'
}
if (normalized.includes('entertainment') || normalized.includes('meal_localized')) {
return '餐饮招待'
}
if (normalized.includes('consecutive_transport')) {
return '交通出行'
}
if (normalized.startsWith('risk.expense.')) {
return '费用科目'
}
return '通用'
}
export function normalizeRiskScenarioCategory(value) {
const normalized = normalizeText(value)
const alias = normalized === '通讯费' ? '通信费' : normalized
return RISK_SCENARIO_VALUES.has(alias) ? alias : ''
}
export function normalizeExpenseTypeScenarioLabels(value) {
const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : []
const labels = []
const seen = new Set()
values.forEach((item) => {
const key = normalizeText(item).toLowerCase()
const label = EXPENSE_TYPE_SCENARIO_LABELS[key] || normalizeRiskScenarioCategory(item)
if (!label || seen.has(label)) {
return
}
seen.add(label)
labels.push(label)
})
return labels
}
export function readRiskRuleExpenseTypes(source) {
const configJson = readConfigJson(source)
const metadata = isPlainObject(configJson.metadata) ? configJson.metadata : {}
const appliesTo = isPlainObject(configJson.applies_to) ? configJson.applies_to : {}
const values = []
;[
configJson.expense_types,
metadata.expense_types,
appliesTo.expense_types,
source?.expense_types
].forEach((item) => {
if (Array.isArray(item)) {
values.push(...item)
} else if (normalizeText(item)) {
values.push(item)
}
})
return values
}
export function readScenarioItems(source) {
if (Array.isArray(source?.scenario_json)) {
return source.scenario_json
}
if (Array.isArray(source?.scenarioList)) {
return source.scenarioList
}
return []
}
export function resolveRiskRuleCategory(source) {
const configJson = readConfigJson(source)
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
if (expenseScenarioLabels.length) {
return formatScenarioList(expenseScenarioLabels)
}
const expenseCategoryLabel =
normalizeText(configJson.expense_category_label) ||
normalizeText(configJson.metadata?.expense_category_label) ||
normalizeText(source?.expense_category_label)
if (expenseCategoryLabel) {
return expenseCategoryLabel
}
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
if (explicit) {
return explicit
}
const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category)
if (payloadCategory) {
return payloadCategory
}
const scenarioItems = readScenarioItems(source)
const businessScenario = scenarioItems
.map((item) => normalizeText(item))
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item))
if (businessScenario) {
return businessScenario
}
return inferRiskCategoryFromCode(source?.code)
}
export function inferFinancialRuleCategory(source) {
const configJson = readConfigJson(source)
const explicit =
normalizeRiskScenarioCategory(configJson.scenario_category) ||
normalizeRiskScenarioCategory(configJson.ai_review_category) ||
normalizeRiskScenarioCategory(configJson.risk_category) ||
normalizeRiskScenarioCategory(source?.scenario_category) ||
normalizeRiskScenarioCategory(source?.risk_category)
if (explicit) {
return explicit
}
const scenarioCategory = readScenarioItems(source)
.map((item) => normalizeRiskScenarioCategory(item))
.find(Boolean)
if (scenarioCategory) {
return scenarioCategory
}
const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {}
const haystack = [
source?.code,
source?.name,
source?.description,
configJson.runtime_kind,
configRuntimeRule.kind,
configRuntimeRule.scenario,
configRuntimeRule.template_key,
...readScenarioItems(source)
]
.map((item) => normalizeText(item).toLowerCase())
.filter(Boolean)
.join(' ')
if (!haystack) {
return '通用'
}
if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) {
return '差旅'
}
if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) {
return '发票'
}
if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) {
return '餐饮招待'
}
if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) {
return '交通出行'
}
if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) {
return '办公物料'
}
if (/(communication|telecom|phone|通信|通讯|手机)/i.test(haystack)) {
return '通信费'
}
if (/(welfare|福利)/i.test(haystack)) {
return '福利费'
}
if (/(expense_standard|费用科目|费用标准|补贴|科目)/i.test(haystack)) {
return '费用科目'
}
return '通用'
}
export function resolveRuleScenarioCategory(source, tabId = '') {
const scenarioList = resolveRuleScenarioList(source, tabId)
if (scenarioList.length) {
return formatScenarioList(scenarioList)
}
return ''
}
export function resolveRuleScenarioList(source, tabId = '') {
const resolvedTabId = tabId || resolveRuleTabId(source)
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
if (expenseScenarioLabels.length) {
return expenseScenarioLabels
}
const riskCategory = resolveRiskRuleCategory(source)
return riskCategory ? [riskCategory] : []
}
if (resolvedTabId === 'financialRules') {
const financialCategory = inferFinancialRuleCategory(source)
return financialCategory ? [financialCategory] : []
}
return []
}
export function buildRiskListSubtitle(text, maxLength = 42) {
const normalized = normalizeText(text)
if (!normalized) {
return '平台内置风险规则'
}
const firstSentence = normalized.split(/[。;;\n]/)[0] || normalized
if (firstSentence.length <= maxLength) {
return firstSentence
}
return `${firstSentence.slice(0, maxLength)}`
}

View File

@@ -0,0 +1,117 @@
import {
EXPENSE_RULE_BLOCK_PATTERN,
RULE_SPREADSHEET_BLOCK_PATTERN,
RULE_TEMPLATE_LABELS
} from './auditViewMetadata.js'
import {
isPlainObject,
normalizeText,
readConfigJson
} from './auditViewDataUtils.js'
export function cloneJsonObject(value) {
if (!isPlainObject(value)) {
return null
}
try {
return JSON.parse(JSON.stringify(value))
} catch {
return { ...value }
}
}
export function resolveRuleTemplateLabel(value) {
const templateKey = normalizeText(value)
return RULE_TEMPLATE_LABELS[templateKey] || templateKey || '未指定模板'
}
export function extractRuntimeRuleFromMarkdown(markdown) {
const match = String(markdown || '').match(EXPENSE_RULE_BLOCK_PATTERN)
if (!match) {
return null
}
try {
const payload = JSON.parse(match[1])
return isPlainObject(payload) ? payload : null
} catch {
return null
}
}
export function extractSpreadsheetMetaFromMarkdown(markdown) {
const match = String(markdown || '').match(RULE_SPREADSHEET_BLOCK_PATTERN)
if (!match) {
return null
}
try {
const payload = JSON.parse(match[1])
return isPlainObject(payload) ? payload : null
} catch {
return null
}
}
export function stripRuntimeRuleBlock(markdown) {
return String(markdown || '')
.replace(EXPENSE_RULE_BLOCK_PATTERN, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stringifyRuntimeRule(runtimeRule) {
return JSON.stringify(isPlainObject(runtimeRule) ? runtimeRule : {}, null, 2)
}
export function parseRuntimeRuleText(runtimeRuleText) {
const text = normalizeText(runtimeRuleText)
if (!text) {
return null
}
try {
const payload = JSON.parse(text)
return isPlainObject(payload) ? payload : null
} catch {
return null
}
}
export function buildDefaultRuntimeRule(source) {
const configJson = readConfigJson(source)
const scenarioItems = Array.isArray(source?.scenario_json)
? source.scenario_json
: Array.isArray(source?.scenarioList)
? source.scenarioList
: []
const configRuntimeRule = cloneJsonObject(configJson.runtime_rule)
return {
kind: normalizeText(configRuntimeRule?.kind || configJson.runtime_kind) || 'policy_rule_draft',
version:
typeof configRuntimeRule?.version === 'number' && Number.isFinite(configRuntimeRule.version)
? configRuntimeRule.version
: 1,
template_key:
normalizeText(configRuntimeRule?.template_key || configJson.rule_template_key) || 'general_policy_v1',
rule_name: normalizeText(configRuntimeRule?.rule_name || source?.name) || '未命名规则',
scenario: normalizeText(configRuntimeRule?.scenario || scenarioItems[0]) || 'expense',
review_required:
typeof configRuntimeRule?.review_required === 'boolean' ? configRuntimeRule.review_required : true
}
}
export function resolveRuntimeRuleForVersion(source, rawMarkdown, runtimeRuleFallback = null) {
return (
cloneJsonObject(extractRuntimeRuleFromMarkdown(rawMarkdown)) ||
cloneJsonObject(runtimeRuleFallback) ||
buildDefaultRuntimeRule(source)
)
}
export function buildMarkdownVersionContent(markdownContent, runtimeRule) {
const body = stripRuntimeRuleBlock(markdownContent)
const runtimeBlock = ['```expense-rule', stringifyRuntimeRule(runtimeRule), '```'].join('\n')
return body ? `${body}\n\n${runtimeBlock}` : runtimeBlock
}