refactor(audit): split list detail flows

This commit is contained in:
caoxiaozhu
2026-05-29 09:44:03 +08:00
parent 064eeb614f
commit 99e90798d2
28 changed files with 2636 additions and 2142 deletions

View File

@@ -241,10 +241,6 @@
.main.settings-main {
grid-template-rows: minmax(0, 1fr);
}
.main.audit-detail-main,
.main.digital-employees-detail-main {
grid-template-rows: minmax(0, 1fr);
}
.workarea { min-height: 0; overflow: auto; padding: 24px; }
.workarea.requests-workarea,
.workarea.documents-workarea,

View File

@@ -243,6 +243,22 @@
gap: 10px;
}
.detail-topbar-actions {
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.detail-kpi-chips {
justify-content: flex-end;
}
.detail-kpi-chip {
min-width: 142px;
}
.detail-alert-strip {
display: flex;
align-items: center;

View File

@@ -1337,213 +1337,6 @@
overflow: hidden;
}
.asset-detail-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.asset-detail-topbar.panel {
padding: 14px 0 10px;
border: 0;
background: transparent;
box-shadow: none;
}
.asset-detail-topbar-main {
flex: 1 1 auto;
min-width: 0;
}
.asset-detail-topbar-main h2 {
margin: 0;
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.25;
}
.asset-detail-topbar-main p {
flex-basis: 100%;
margin: 0;
max-width: 860px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.asset-detail-topbar-meta {
flex: 0 0 auto;
justify-content: flex-end;
}
.asset-detail-topbar .hero-review-meta {
flex-basis: 100%;
margin-top: 2px;
}
.asset-detail-topbar .review-note-block {
flex-basis: 100%;
margin-top: 4px;
}
.asset-detail-topbar .hero-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.asset-detail-topbar .hero-stat {
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
}
.asset-detail-topbar .hero-stat span {
display: none;
}
.asset-detail-topbar .hero-stat strong {
font-size: 12px;
font-weight: 800;
}
.json-risk-editor-head {
align-items: center;
padding-top: 4px;
padding-bottom: 8px;
}
.json-risk-score-ring {
--score-ring: #f97316;
--score-ring-bg: #fff7ed;
flex: 0 0 auto;
width: 82px;
height: 82px;
border-radius: 999px;
display: grid;
place-items: center;
align-content: center;
gap: 1px;
border: 2px solid var(--score-ring);
background: var(--score-ring-bg);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.json-risk-score-ring strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
line-height: 1;
}
.json-risk-score-ring span,
.json-risk-score-ring em {
color: #64748b;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 1.1;
}
.json-risk-score-ring em {
color: var(--score-ring);
}
.json-risk-score-ring.low {
--score-ring: #2563eb;
--score-ring-bg: #eff6ff;
}
.json-risk-score-ring.medium {
--score-ring: #f97316;
--score-ring-bg: #fff7ed;
}
.json-risk-score-ring.high {
--score-ring: #dc2626;
--score-ring-bg: #fef2f2;
}
.json-risk-score-ring.critical {
--score-ring: #991b1b;
--score-ring-bg: #fff1f2;
}
.json-risk-editor-title {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.json-risk-head-copy {
min-width: 0;
display: grid;
gap: 6px;
}
.json-risk-head-title-row {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.json-risk-editor-title h2 {
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.25;
}
.json-risk-editor-title p {
margin-top: 2px;
max-width: 760px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.json-risk-head-subtitle {
display: -webkit-box;
margin: 0;
max-width: 760px;
overflow: hidden;
color: #64748b;
font-size: 13px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.json-risk-head-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.json-risk-head-meta span {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 750;
border: 1px solid #e2e8f0;
}
.skill-name-cell .skill-list-subtitle {
display: -webkit-box;
overflow: hidden;
@@ -1554,40 +1347,6 @@
-webkit-line-clamp: 2;
}
.json-risk-editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.json-risk-mode-pill {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #fff1f2;
color: #be123c;
font-size: 12px;
font-weight: 800;
}
.json-risk-mode-pill.high {
background: #fef2f2;
color: #dc2626;
}
.json-risk-mode-pill.medium {
background: #fff7ed;
color: #ea580c;
}
.json-risk-mode-pill.low {
background: var(--success-soft);
color: var(--success-hover);
}
.json-risk-editor-body {
flex: 1 1 auto;
min-height: 0;
@@ -1853,15 +1612,3 @@
grid-column: span 1;
}
}
@media (max-width: 860px) {
.json-risk-editor-head {
flex-direction: column;
align-items: stretch;
}
.json-risk-editor-actions {
justify-content: flex-start;
}
}

View File

@@ -609,7 +609,8 @@ tbody tr.is-disabled:hover {
min-width: 0;
}
.skill-badge {
.skill-badge,
.skill-detail :deep(.skill-badge) {
display: inline-flex;
align-items: center;
min-height: 24px;
@@ -620,11 +621,16 @@ tbody tr.is-disabled:hover {
font-weight: 800;
}
.skill-badge.primary { background: var(--theme-gradient-primary); }
.skill-badge.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.skill-badge.primary,
.skill-detail :deep(.skill-badge.primary) { background: var(--theme-gradient-primary); }
.skill-badge.rose,
.skill-detail :deep(.skill-badge.rose) { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.skill-badge.violet,
.skill-detail :deep(.skill-badge.violet) { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-badge.blue,
.skill-detail :deep(.skill-badge.blue) { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-badge.amber,
.skill-detail :deep(.skill-badge.amber) { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.hero-title h2 {
margin-top: 10px;
@@ -868,41 +874,6 @@ tbody tr.is-disabled:hover {
padding: 10px;
}
.spreadsheet-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.spreadsheet-editor-title {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 12px;
}
.spreadsheet-editor-title h2 {
color: #0f172a;
font-size: 18px;
font-weight: 850;
}
.spreadsheet-editor-title p {
margin-top: 2px;
max-width: 760px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.spreadsheet-editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.spreadsheet-editor-meta {
display: flex;
gap: 8px;

View File

@@ -1,23 +1,5 @@
<template>
<section class="json-risk-editor-shell panel digital-worker-detail-shell">
<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="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
</div>
<p class="json-risk-head-subtitle">
{{ selectedSkill.summary || '后台自动执行的数字员工技能。' }}
</p>
<div class="json-risk-head-meta">
<span>技能编号{{ selectedSkill.code || '-' }}</span>
<span>执行计划{{ digitalEmployee.scheduleLabel || selectedSkill.scope || '-' }}</span>
<span>最近更新{{ selectedSkill.updatedAt || '-' }}</span>
</div>
</div>
</div>
</header>
<div class="json-risk-editor-body">
<section class="json-risk-main-stage">
<article class="detail-card panel json-risk-summary-card">

View File

@@ -1,31 +1,5 @@
<template>
<section class="json-risk-editor-shell panel">
<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="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
</div>
<p class="json-risk-head-subtitle">
{{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }}
</p>
<div class="json-risk-head-meta">
<span v-if="selectedSkill.riskCategory">适用场景{{ selectedSkill.riskCategory }}</span>
<span>业务域{{ selectedSkill.category || '-' }}</span>
<span>最近更新{{ selectedSkill.updatedAt || '-' }}</span>
</div>
</div>
</div>
<div
class="json-risk-score-ring"
:class="selectedSkill.riskRuleScoreLevel || selectedSkill.riskRuleSeverity"
>
<strong>{{ selectedSkill.riskRuleScore ?? '--' }}</strong>
<span>风险分</span>
<em>{{ selectedSkill.riskRuleScoreLabel || selectedSkill.riskRuleSeverityLabel }}</em>
</div>
</header>
<div
v-if="selectedSkill.riskRuleGenerationFailed"
class="json-risk-generation-failure"

View File

@@ -1,21 +1,5 @@
<template>
<section class="spreadsheet-editor-shell panel">
<header class="spreadsheet-editor-head asset-detail-topbar list-toolbar">
<div class="spreadsheet-editor-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
</div>
</div>
<div class="spreadsheet-editor-actions asset-detail-topbar-meta toolbar-actions">
<span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetModeLabel }}
</span>
</div>
</header>
<input
ref="fileInput"
class="spreadsheet-upload-input"
@@ -126,7 +110,6 @@ defineOptions({
defineProps({
selectedSkill: { type: Object, required: true },
selectedSpreadsheetModeLabel: { type: String, default: '' },
selectedSpreadsheetFileName: { type: String, default: '' },
selectedSpreadsheetChangeRecords: { type: Array, default: () => [] },
spreadsheetOnlyOfficeHostId: { type: String, required: true },

View File

@@ -175,32 +175,6 @@
<!-- 详情视图 (全屏样式参考 AuditJsonRiskRuleDetail) -->
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage">
<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="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ resolveWorkRecordTitle(selectedRunDetail) }}</h2>
</div>
<p class="json-risk-head-subtitle">
执行工作流{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}
</p>
<div class="json-risk-head-meta">
<span>Run ID{{ selectedRunDetail.run_id }}</span>
<span>触发来源{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</span>
<span>开始时间{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</span>
</div>
</div>
</div>
<div
class="json-risk-score-ring"
:class="selectedRunDetail.status"
>
<strong style="font-size: 16px; font-weight: 900;">{{ resolveWorkRecordStatusLabel(selectedRunDetail) }}</strong>
<span>运行状态</span>
<em>{{ resolveWorkRecordStatusNote(selectedRunDetail) || '执行完毕' }}</em>
</div>
</header>
<div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;">
<TableLoadingState
variant="panel"
@@ -345,7 +319,7 @@ defineOptions({
name: 'DigitalEmployeeWorkRecords'
})
const emit = defineEmits(['summary-change', 'detail-open-change'])
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
const runs = ref([])
@@ -362,9 +336,57 @@ watch(detailOpen, (newVal) => {
}, { immediate: true })
let pollTimer = 0
const totalCount = computed(() => runs.value.length)
const successCount = computed(() => runs.value.filter((run) => run.status === 'succeeded').length)
const failedCount = computed(() => runs.value.filter((run) => run.status === 'failed').length)
const workRecordSummary = computed(() =>
runs.value.reduce(
(summary, run) => {
summary.total += 1
if (run.status === 'succeeded') {
summary.succeeded += 1
} else if (run.status === 'failed') {
summary.failed += 1
}
return summary
},
{ total: 0, succeeded: 0, failed: 0 }
)
)
const workRecordDetailTopBar = computed(() => {
const detail = selectedRunDetail.value
if (!detail) return null
const status = String(detail.status || '').toLowerCase()
const statusColor =
status === 'failed'
? '#ef4444'
: status === 'succeeded'
? 'var(--success)'
: '#3b82f6'
return {
view: {
title: resolveWorkRecordTitle(detail),
desc: `执行工作流:${resolveWorkRecordModuleLabel(detail)}`
},
kpis: [
{
label: '运行状态',
value: resolveWorkRecordStatusLabel(detail),
unit: '',
meta: resolveWorkRecordStatusNote(detail) || '执行完毕',
trend: status === 'failed' ? 'down' : 'up',
color: statusColor
}
]
}
})
watch(
workRecordDetailTopBar,
(value) => {
emit('detail-topbar-change', value)
},
{ immediate: true, deep: true }
)
const listKeyword = ref('')
const activeModule = ref('全部')
@@ -479,9 +501,9 @@ async function loadWorkRecords(showToast = false) {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
runs.value = Array.isArray(payload) ? payload : []
emit('summary-change', {
total: totalCount.value,
succeeded: successCount.value,
failed: failedCount.value
total: workRecordSummary.value.total,
succeeded: workRecordSummary.value.succeeded,
failed: workRecordSummary.value.failed
})
} catch (error) {
errorMessage.value = error?.message || '工作记录加载失败,请稍后重试。'
@@ -646,19 +668,4 @@ onBeforeUnmount(() => {
min-height: 0;
}
/* 风险环的成功、失败、执行中状态配色 */
.json-risk-score-ring.succeeded {
--score-ring: #16a34a;
--score-ring-bg: #f0fdf4;
}
.json-risk-score-ring.failed {
--score-ring: #dc2626;
--score-ring-bg: #fef2f2;
}
.json-risk-score-ring.running {
--score-ring: #2563eb;
--score-ring-bg: #eff6ff;
}
</style>

View File

@@ -84,7 +84,20 @@
</template>
<template v-else-if="isRequestDetail">
<div class="detail-alert-strip">
<div class="detail-topbar-actions">
<div v-if="detailKpis.length" class="kpi-chips detail-kpi-chips">
<div
v-for="kpi in detailKpis"
:key="kpi.label"
class="kpi-chip detail-kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
<div v-if="detailAlerts.length" class="detail-alert-strip">
<span
v-for="alert in detailAlerts"
:key="alert.label"
@@ -95,6 +108,7 @@
<span>{{ alert.label }}</span>
</span>
</div>
</div>
</template>
<template v-else-if="isWorkbench">
@@ -247,6 +261,10 @@ const props = defineProps({
type: Array,
default: () => []
},
detailKpis: {
type: Array,
default: () => []
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
@@ -265,7 +283,7 @@ const emit = defineEmits([
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents'].includes(props.activeView) && props.detailMode)
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)

View File

@@ -55,8 +55,8 @@
}"
>
<TopBar
v-if="activeView !== 'settings' && !(activeView === 'audit' && auditDetailOpen) && !(activeView === 'digitalEmployees' && digitalEmployeeDetailOpen)"
:current-view="topBarView"
v-if="activeView !== 'settings'"
:current-view="resolvedTopBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
@@ -68,9 +68,10 @@
:document-summary="documentSummary"
:digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="detailMode"
:detail-mode="resolvedDetailMode"
:log-detail-mode="logDetailMode"
:detail-alerts="detailAlerts"
:detail-alerts="resolvedDetailAlerts"
:detail-kpis="resolvedDetailKpis"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@@ -145,11 +146,16 @@
@open-assistant="openSmartEntry"
/>
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<AuditView
v-else-if="activeView === 'audit'"
@detail-open-change="auditDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<DigitalEmployeesView
v-else-if="activeView === 'digitalEmployees'"
@summary-change="digitalEmployeeSummary = $event"
@detail-open-change="digitalEmployeeDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
@@ -175,35 +181,37 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, 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 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 BudgetCenterView from './BudgetCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import DigitalEmployeesView from './DigitalEmployeesView.vue'
import LogsView from './LogsView.vue'
import LogDetailView from './LogDetailView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import SettingsView from './SettingsView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.js'
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
const OverviewView = defineAsyncComponent(() => import('./OverviewView.vue'))
const PersonalWorkbenchView = defineAsyncComponent(() => import('./PersonalWorkbenchView.vue'))
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue'))
const BudgetCenterView = defineAsyncComponent(() => import('./BudgetCenterView.vue'))
const PoliciesView = defineAsyncComponent(() => import('./PoliciesView.vue'))
const AuditView = defineAsyncComponent(() => import('./AuditView.vue'))
const DigitalEmployeesView = defineAsyncComponent(() => import('./DigitalEmployeesView.vue'))
const LogsView = defineAsyncComponent(() => import('./LogsView.vue'))
const LogDetailView = defineAsyncComponent(() => import('./LogDetailView.vue'))
const EmployeeManagementView = defineAsyncComponent(() => import('./EmployeeManagementView.vue'))
const SettingsView = defineAsyncComponent(() => import('./SettingsView.vue'))
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const logsSummary = ref(null)
const documentSummary = ref(null)
const digitalEmployeeSummary = ref(null)
const detailTopBarPayload = ref(null)
const auditDetailOpen = ref(false)
const digitalEmployeeDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
@@ -282,6 +290,37 @@ const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
const DETAIL_TOPBAR_FALLBACKS = {
audit: {
title: '规则中心详情',
desc: '查看规则配置、版本审核、测试结果与上线状态。'
},
digitalEmployees: {
title: '数字员工详情',
desc: '查看数字员工配置、执行计划、运行记录与源文件。'
}
}
const customDetailTopBarActive = computed(() => (
(activeView.value === 'audit' && auditDetailOpen.value) ||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value)
))
const resolvedTopBarView = computed(() => (
customDetailTopBarActive.value
? detailTopBarPayload.value?.view || DETAIL_TOPBAR_FALLBACKS[activeView.value] || topBarView.value
: topBarView.value
))
const resolvedDetailMode = computed(() => (
detailMode.value ||
customDetailTopBarActive.value
))
const resolvedDetailAlerts = computed(() => (
customDetailTopBarActive.value
? detailTopBarPayload.value?.alerts || []
: detailAlerts.value
))
const resolvedDetailKpis = computed(() => (
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
))
function handleLogout() {
logout('manual')

View File

@@ -11,67 +11,6 @@
}"
>
<div class="detail-scroll">
<section
v-if="!selectedSkill.usesSpreadsheetRule && !selectedSkill.usesJsonRiskRule"
class="detail-hero panel asset-detail-topbar list-toolbar"
>
<div class="hero-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
<div class="hero-review-meta">
<span>
<i class="mdi mdi-code-tags"></i>
{{ selectedSkill.code }}
</span>
<span>
<i class="mdi mdi-account-outline"></i>
负责人{{ selectedSkill.owner }}
</span>
<span>
<i class="mdi mdi-account-check-outline"></i>
审核人{{ selectedSkill.reviewer }}
</span>
<b :class="['status-pill', selectedSkill.statusTone]">{{ selectedSkill.status }}</b>
<b
v-if="selectedSkillIsRule"
:class="['status-pill', selectedSkill.reviewStatusTone]"
>
{{ selectedSkill.reviewStatusLabel }}
</b>
</div>
<div v-if="selectedSkillIsRule" class="review-note-block">
<strong>上线约束</strong>
<p>{{ activateBlockedReason || '当前规则版本审核通过后可正式上线。' }}</p>
<span v-if="showReviewNote">
审核时间{{ selectedSkill.reviewTimeLabel }}
<template v-if="selectedSkill.reviewNote"> · 审核意见{{ selectedSkill.reviewNote }}</template>
</span>
</div>
</div>
<div class="hero-stats asset-detail-topbar-meta toolbar-actions">
<div class="hero-stat">
<span>资产编码</span>
<strong>{{ selectedSkill.code }}</strong>
</div>
<div class="hero-stat">
<span>业务域</span>
<strong>{{ selectedSkill.category }}</strong>
</div>
<div class="hero-stat">
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
</div>
<div class="hero-stat">
<span>最近更新</span>
<strong>{{ selectedSkill.updatedAt }}</strong>
</div>
</div>
</section>
<section v-if="detailError" class="detail-inline-state panel error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
@@ -94,7 +33,6 @@
v-else-if="selectedSkill.usesSpreadsheetRule"
ref="spreadsheetUploadInput"
:selected-skill="selectedSkill"
:selected-spreadsheet-mode-label="selectedSpreadsheetModeLabel"
:selected-spreadsheet-file-name="selectedSpreadsheetFileName"
:selected-spreadsheet-change-records="selectedSpreadsheetChangeRecords"
:spreadsheet-only-office-host-id="spreadsheetOnlyOfficeHostId"

View File

@@ -267,6 +267,7 @@
class="digital-work-records-section"
@summary-change="emit('summary-change', $event)"
@detail-open-change="workRecordDetailOpen = $event"
@detail-topbar-change="workRecordDetailTopBar = $event"
/>
</article>
</Transition>
@@ -307,8 +308,6 @@ import { runOrchestrator } from '../services/orchestrator.js'
import { isPlatformAdminUser } from '../utils/accessControl.js'
import {
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
buildDigitalEmployeeDetailMeta,
buildDigitalEmployeeListMeta,
formatDigitalEmployeeCron,
isDigitalEmployeeAsset
} from './scripts/auditViewDigitalEmployeeModel.js'
@@ -319,28 +318,55 @@ import {
resolveDigitalEmployeeScheduleValue
} from './scripts/digitalEmployeeScheduleModel.js'
import { incrementVersion } from './scripts/auditViewRuntimeModel.js'
import {
DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS,
buildDigitalEmployeeDetailTopBar,
buildEmployeeDetail,
buildEmployeeListItem,
buildEmployeePlaceholder,
filterDigitalEmployees,
sortEmployees
} from './scripts/digitalEmployeesViewModel.js'
import {
ENABLED_STATE_OPTIONS,
formatDateTime,
normalizeText,
resolveStatusMeta,
STATUS_OPTIONS
} from './scripts/auditViewModel.js'
const { currentUser } = useSystemState()
const { toast } = useToast()
const emit = defineEmits(['summary-change', 'detail-open-change'])
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const employees = ref([])
const selectedEmployee = ref(null)
const selectedEmployeeId = ref('')
const activeSection = ref('skills')
const workRecordDetailOpen = ref(false)
const workRecordDetailTopBar = ref(null)
const isDetailOpen = computed(() => Boolean(selectedEmployee.value) || (activeSection.value === 'workRecords' && workRecordDetailOpen.value))
const digitalEmployeeDetailTopBar = computed(() => {
const employee = selectedEmployee.value
if (employee) {
return buildDigitalEmployeeDetailTopBar(employee)
}
if (activeSection.value === 'workRecords' && workRecordDetailOpen.value) {
return workRecordDetailTopBar.value
}
return null
})
watch(isDetailOpen, (newVal) => {
emit('detail-open-change', newVal)
}, { immediate: true })
watch(
digitalEmployeeDetailTopBar,
(value) => {
emit('detail-topbar-change', value)
},
{ immediate: true, deep: true }
)
const keyword = ref('')
const selectedStatus = ref('')
const selectedEnabledState = ref('')
@@ -365,11 +391,7 @@ const scheduleEditorBusy = computed(() => actionState.value === 'save-digital-sc
const statusOptions = STATUS_OPTIONS
const enabledStateOptions = ENABLED_STATE_OPTIONS
const executionModeOptions = [
{ value: '', label: '全部执行方式' },
{ value: 'timed', label: '定时执行' },
{ value: 'manual', label: '手动触发' }
]
const executionModeOptions = DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS
const selectedStatusLabel = computed(() =>
statusOptions.find((item) => item.value === selectedStatus.value)?.label || '全部状态'
@@ -403,32 +425,11 @@ const schedulePreviewLabel = computed(() => {
})
const visibleEmployees = computed(() => {
const searchText = normalizeText(keyword.value).toLowerCase()
return employees.value.filter((item) => {
const matchesKeyword = searchText
? [
item.name,
item.code,
item.summary,
item.owner,
item.scope,
item.executionMode,
item.skillCategory,
item.status,
item.enabledLabel
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(searchText))
: true
const matchesStatus = selectedStatus.value ? item.statusValue === selectedStatus.value : true
const matchesEnabled = selectedEnabledState.value
? (selectedEnabledState.value === 'enabled') === Boolean(item.isEnabledValue)
: true
const matchesExecutionMode = selectedExecutionMode.value
? item.executionModeValue === selectedExecutionMode.value
: true
return matchesKeyword && matchesStatus && matchesEnabled && matchesExecutionMode
return filterDigitalEmployees(employees.value, {
keyword: keyword.value,
selectedEnabledState: selectedEnabledState.value,
selectedExecutionMode: selectedExecutionMode.value,
selectedStatus: selectedStatus.value
})
})
@@ -466,91 +467,6 @@ function resolveActor() {
return normalizeText(user.name) || normalizeText(user.username) || 'system'
}
function buildEmployeeListItem(asset) {
const meta = buildDigitalEmployeeListMeta(asset)
const statusMeta = resolveStatusMeta(asset.status)
const displayName = meta.name || '数字员工技能'
return {
id: asset.id,
rawCode: asset.code,
short: displayName.slice(0, 2),
badgeTone: 'blue',
name: displayName,
code: meta.code,
summary: meta.summary,
owner: meta.owner,
scope: meta.scope,
executionMode: meta.executionMode,
executionModeValue: meta.executionMode === '定时执行' ? 'timed' : 'manual',
skillCategory: meta.skillCategory,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
enabledLabel: meta.enabledLabel,
enabledTone: meta.enabledTone,
isEnabledValue: meta.enabled,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
updatedAtRaw: asset.updated_at || '',
digitalEmployee: meta
}
}
function buildEmployeePlaceholder(employee) {
return {
...employee,
type: 'digitalEmployees',
typeLabel: '数字员工',
currentVersion: employee.currentVersion || employee.version || '-',
workingVersion: employee.version || '-',
markdownContent: '',
loading: true
}
}
function buildEmployeeDetail(asset) {
const meta = buildDigitalEmployeeDetailMeta({
...asset,
updated_at: formatDateTime(asset.updated_at)
})
const statusMeta = resolveStatusMeta(asset.status)
return {
id: asset.id,
type: 'digitalEmployees',
typeLabel: '数字员工',
rawCode: asset.code,
short: meta.name.slice(0, 2),
name: meta.name,
code: meta.code,
summary: meta.description,
owner: meta.owner,
reviewer: meta.reviewer,
category: meta.category,
scope: meta.scope,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
workingVersion: asset.working_version || asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
markdownContent: meta.sourceMarkdown,
digitalEmployee: meta,
loading: false
}
}
function sortEmployees(items) {
return [...items].sort((left, right) =>
String(right.updatedAtRaw || '').localeCompare(String(left.updatedAtRaw || ''))
)
}
async function loadEmployees() {
loading.value = true
errorMessage.value = ''

View File

@@ -15,7 +15,7 @@
</article>
<template v-else-if="isHermes && hermesRun">
<article v-if="!isKnowledgeIngestRunDetail" class="detail-hero panel">
<article class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
@@ -43,12 +43,7 @@
{{ hermesRunAlert.message }}
</article>
<KnowledgeIngestRunPanel
v-if="isKnowledgeIngestRunDetail"
:run="hermesRun"
/>
<div v-if="!isKnowledgeIngestRunDetail" class="detail-grid">
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>基本信息</h3>
@@ -68,7 +63,7 @@
</div>
</article>
<article v-if="!isKnowledgeIngestRunDetail" class="panel detail-card">
<article class="panel detail-card">
<div class="card-head">
<h3>处理链路</h3>
<p>按工具调用顺序查看执行链</p>
@@ -97,7 +92,7 @@
</div>
</article>
<article v-if="selectedToolCall && !isKnowledgeIngestRunDetail" class="panel detail-card">
<article v-if="selectedToolCall" class="panel detail-card">
<div class="card-head">
<h3>当前 ToolCall</h3>
<p>查看当前工具调用的请求与返回</p>
@@ -199,7 +194,6 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KnowledgeIngestRunPanel from '../components/logs/KnowledgeIngestRunPanel.vue'
import { fetchAgentRunDetail } from '../services/agentAssets.js'
import { fetchSystemLogEntry } from '../services/systemLogs.js'
import {
@@ -210,7 +204,6 @@ import {
resolveAgentRunHeartbeat,
resolveAgentRunStatus
} from '../utils/agentRunMonitor.js'
import { isKnowledgeIngestRun } from '../utils/knowledgeIngestLogModel.js'
const SOURCE_LABELS = {
schedule: '定时任务',
@@ -230,7 +223,6 @@ let pollTimer = 0
const isHermes = computed(() => route.params.logKind === 'hermes')
const isSystem = computed(() => route.params.logKind === 'system')
const isKnowledgeIngestRunDetail = computed(() => isKnowledgeIngestRun(hermesRun.value))
const selectedToolCall = computed(() =>
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
import { normalizeText } from './auditViewModel.js'
function resolveRiskScoreCardColor(level) {
const normalized = normalizeText(level).toLowerCase()
if (['critical', 'high'].includes(normalized)) return '#ef4444'
if (['medium', 'warning'].includes(normalized)) return '#f59e0b'
if (['low', 'success'].includes(normalized)) return 'var(--success)'
return 'var(--theme-primary)'
}
export function buildAuditDetailTopBar({
skill,
usesJsonRiskRule = false,
usesSpreadsheetRule = false,
spreadsheetModeLabel = '',
spreadsheetFileName = '',
canEditSpreadsheetInline = false
} = {}) {
if (!skill) return null
const title = normalizeText(skill.name) || '规则中心详情'
const desc =
normalizeText(skill.riskRuleSubtitle) ||
normalizeText(skill.summary) ||
normalizeText(skill.configDesc) ||
'查看规则配置、版本审核、测试结果与上线状态。'
const kpis = []
if (usesJsonRiskRule) {
const scoreLevel = skill.riskRuleScoreLevel || skill.riskRuleSeverity
const score = skill.riskRuleScore ?? '--'
kpis.push({
label: '风险分',
value: String(score),
unit: score === '--' ? '' : '分',
meta: normalizeText(skill.riskRuleScoreLabel || skill.riskRuleSeverityLabel) || '待评估',
trend: ['critical', 'high', 'medium', 'warning'].includes(normalizeText(scoreLevel).toLowerCase())
? 'down'
: 'up',
color: resolveRiskScoreCardColor(scoreLevel)
})
} else if (usesSpreadsheetRule) {
kpis.push({
label: '编辑模式',
value: spreadsheetModeLabel,
unit: '',
meta: spreadsheetFileName,
trend: canEditSpreadsheetInline ? 'up' : 'down',
color: canEditSpreadsheetInline ? 'var(--success)' : '#64748b'
})
}
return {
view: { title, desc },
kpis
}
}

View File

@@ -0,0 +1,235 @@
import { computed } from 'vue'
import {
ENABLED_STATE_OPTIONS,
ONLINE_STATE_OPTIONS,
RISK_SCENARIO_OPTIONS,
STATUS_OPTIONS
} from './auditViewMetadata.js'
import { RISK_RULE_LEVEL_OPTIONS } from './auditViewRiskRuleModel.js'
import {
normalizeText,
resolveDomainLabel,
resolveStatusMeta
} from './auditViewModel.js'
import { filterAuditAssets } from './auditViewRuntimeModel.js'
function buildOptions(items, valueGetter, labelGetter, defaultLabel) {
const values = []
const seen = new Set()
items.forEach((item) => {
const value = normalizeText(valueGetter(item))
if (!value || seen.has(value)) {
return
}
seen.add(value)
values.push(value)
})
return [
{ value: '', label: defaultLabel },
...values.map((value) => ({
value,
label: labelGetter(value)
}))
]
}
export function useAuditListFilters({
activeType,
activeTabLabel,
currentAssets,
keyword,
selectedDomain,
selectedOwner,
selectedRiskLevel,
selectedStatus,
selectedRiskScenario,
selectedOnlineState,
selectedEnabledState
}) {
const showRiskScenarioFilter = computed(() =>
['financialRules', 'riskRules'].includes(activeType.value)
)
const showOwnerFilter = computed(() => activeType.value !== 'riskRules')
const showRiskLevelFilter = computed(() => activeType.value === 'riskRules')
const showStatusFilter = computed(() => true)
const showOnlineFilter = computed(() => false)
const showEnabledFilter = computed(() => false)
const domainOptions = computed(() =>
buildOptions(
currentAssets.value,
(item) => item.domainValue,
(value) => resolveDomainLabel(value),
'全部业务域'
)
)
const ownerOptions = computed(() =>
buildOptions(
currentAssets.value,
(item) => item.owner,
(value) => value,
'全部负责人'
)
)
const riskLevelOptions = computed(() => [
{ value: '', label: '全部风险等级' },
...RISK_RULE_LEVEL_OPTIONS
])
const selectedDomainLabel = computed(
() => domainOptions.value.find((item) => item.value === selectedDomain.value)?.label || '业务域'
)
const selectedOwnerLabel = computed(
() =>
ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label ||
'负责人'
)
const selectedRiskLevelLabel = computed(
() =>
riskLevelOptions.value.find((item) => item.value === selectedRiskLevel.value)?.label ||
'风险等级'
)
const selectedStatusLabel = computed(
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
)
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 = []
if (selectedDomain.value) {
tokens.push(`业务域:${resolveDomainLabel(selectedDomain.value)}`)
}
if (showRiskScenarioFilter.value && selectedRiskScenario.value) {
tokens.push(`使用场景:${selectedRiskScenario.value}`)
}
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 (showOwnerFilter.value && selectedOwner.value) {
tokens.push(`负责人:${selectedOwner.value}`)
}
if (showRiskLevelFilter.value && selectedRiskLevel.value) {
tokens.push(`风险等级:${selectedRiskLevelLabel.value}`)
}
if (keyword.value.trim()) {
tokens.push(`搜索:${keyword.value.trim()}`)
}
return tokens
})
const visibleSkills = computed(() =>
filterAuditAssets(currentAssets.value, {
keyword: keyword.value,
selectedDomain: selectedDomain.value,
selectedOwner: selectedOwner.value,
selectedRiskLevel: selectedRiskLevel.value,
selectedStatus: selectedStatus.value,
selectedRiskScenario: selectedRiskScenario.value,
selectedOnlineState: selectedOnlineState.value,
selectedEnabledState: selectedEnabledState.value,
showStatusFilter: showStatusFilter.value,
showRiskScenarioFilter: showRiskScenarioFilter.value,
showOnlineFilter: showOnlineFilter.value,
showEnabledFilter: showEnabledFilter.value
})
)
const auditEmptyState = computed(() => {
const hasFilters = activeFilterTokens.value.length > 0
const supportedFilters = [
'业务域',
...(showOwnerFilter.value ? ['负责人'] : []),
...(showRiskLevelFilter.value ? ['风险等级'] : []),
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
...(showStatusFilter.value ? ['状态'] : []),
...(showOnlineFilter.value ? ['是否上线'] : []),
...(showEnabledFilter.value ? ['是否启用'] : []),
'关键词'
]
if (!currentAssets.value.length) {
return {
eyebrow: `${activeTabLabel.value}资产`,
title: `${activeTabLabel.value}列表暂时还是空的`,
desc: `当前环境里还没有可展示的${activeTabLabel.value}资产。完成接入或同步后,会统一展示在这里。`,
icon: 'mdi mdi-database-search-outline',
actionLabel: '',
actionIcon: '',
tone: 'amber',
artLabel: 'ASSET',
tips: [
'切换页签可查看其他资产类型',
`支持按${supportedFilters.slice(0, -1).join('、')}和关键词做过滤`
]
}
}
return {
eyebrow: '筛选结果为空',
title: `没有找到匹配的${activeTabLabel.value}`,
desc: hasFilters
? `试试清空${supportedFilters.join('、')}筛选,再重新查看。`
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
actionLabel: hasFilters ? '清空筛选' : '',
actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : '',
tone: hasFilters ? 'primary' : 'slate',
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
tips: hasFilters
? [
`${supportedFilters.join('、')}会叠加过滤`,
showRiskScenarioFilter.value
? '可以换个规则名称或场景分类继续搜索'
: '可以换个编码、名称或负责人关键词继续搜索'
]
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
}
})
return {
activeFilterTokens,
auditEmptyState,
domainOptions,
ownerOptions,
riskLevelOptions,
selectedDomainLabel,
selectedEnabledStateLabel,
selectedOnlineStateLabel,
selectedOwnerLabel,
selectedRiskLevelLabel,
selectedRiskScenarioLabel,
selectedStatusLabel,
showEnabledFilter,
showOnlineFilter,
showOwnerFilter,
showRiskLevelFilter,
showRiskScenarioFilter,
showStatusFilter,
visibleSkills
}
}

View File

@@ -1043,6 +1043,17 @@ export function buildListItem(asset) {
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,
@@ -1092,7 +1103,8 @@ export function buildListItem(asset) {
changeCount,
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
domainValue: asset.domain
domainValue: asset.domain,
searchText
}
}

View File

@@ -84,50 +84,53 @@ export function buildSpreadsheetChangeRecordKey(records = []) {
export function filterAuditAssets(assets = [], filters = {}) {
const normalizedKeyword = normalizeText(filters.keyword).toLowerCase()
const hasKeyword = Boolean(normalizedKeyword)
const hasDomain = Boolean(filters.selectedDomain)
const hasOwner = Boolean(filters.selectedOwner)
const hasRiskLevel = Boolean(filters.selectedRiskLevel)
const hasStatus = Boolean(filters.showStatusFilter && filters.selectedStatus)
const hasRiskScenario = Boolean(filters.showRiskScenarioFilter && filters.selectedRiskScenario)
const hasOnline = Boolean(filters.showOnlineFilter && filters.selectedOnlineState)
const hasEnabled = Boolean(filters.showEnabledFilter && filters.selectedEnabledState)
return assets.filter((item) => {
const matchesKeyword = normalizedKeyword
? [item.name, item.code, item.summary, item.owner, item.scope, item.riskLevelLabel]
if (hasKeyword) {
const searchText = item.searchText || [item.name, item.code, item.summary, item.owner, item.scope, item.riskLevelLabel]
.map((value) => normalizeText(value).toLowerCase())
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
: true
const matchesDomain = filters.selectedDomain ? item.domainValue === filters.selectedDomain : true
const matchesOwner = filters.selectedOwner ? item.owner === filters.selectedOwner : true
const matchesRiskLevel = filters.selectedRiskLevel
? item.riskLevelValue === filters.selectedRiskLevel
: true
const matchesStatus = filters.showStatusFilter
? filters.selectedStatus
? item.statusValue === filters.selectedStatus
: true
: true
const matchesRiskScenario = filters.showRiskScenarioFilter
? filters.selectedRiskScenario
? Array.isArray(item.scenarioList) && item.scenarioList.length
.join(' ')
if (!searchText.includes(normalizedKeyword)) {
return false
}
}
if (hasDomain && item.domainValue !== filters.selectedDomain) {
return false
}
if (hasOwner && item.owner !== filters.selectedOwner) {
return false
}
if (hasRiskLevel && item.riskLevelValue !== filters.selectedRiskLevel) {
return false
}
if (hasStatus && item.statusValue !== filters.selectedStatus) {
return false
}
if (hasRiskScenario) {
const hasScenarioList = Array.isArray(item.scenarioList) && item.scenarioList.length
const matched = hasScenarioList
? item.scenarioList.includes(filters.selectedRiskScenario)
: 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
if (!matched) {
return false
}
}
if (hasOnline && (filters.selectedOnlineState === 'online') !== Boolean(item.isOnlineValue)) {
return false
}
if (hasEnabled && (filters.selectedEnabledState === 'enabled') !== Boolean(item.isEnabledValue)) {
return false
}
return (
matchesKeyword &&
matchesDomain &&
matchesOwner &&
matchesRiskLevel &&
matchesStatus &&
matchesRiskScenario &&
matchesOnline &&
matchesEnabled
)
return true
})
}

View File

@@ -0,0 +1,180 @@
import {
buildDigitalEmployeeDetailMeta,
buildDigitalEmployeeListMeta
} from './auditViewDigitalEmployeeModel.js'
import {
formatDateTime,
normalizeText,
resolveStatusMeta
} from './auditViewModel.js'
export const DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS = [
{ value: '', label: '全部执行方式' },
{ value: 'timed', label: '定时执行' },
{ value: 'manual', label: '手动触发' }
]
export function resolveDigitalEmployeeStatusColor(statusValue) {
const normalized = normalizeText(statusValue).toLowerCase()
if (normalized === 'active') return 'var(--success)'
if (['failed', 'error'].includes(normalized)) return '#ef4444'
if (['disabled', 'inactive'].includes(normalized)) return '#f59e0b'
return 'var(--theme-primary)'
}
export function buildDigitalEmployeeDetailTopBar(employee) {
if (!employee) return null
const meta = employee.digitalEmployee || {}
const statusValue = normalizeText(employee.statusValue).toLowerCase()
return {
view: {
title: normalizeText(employee.name) || '数字员工详情',
desc:
normalizeText(meta.description) ||
normalizeText(employee.summary) ||
'查看数字员工配置、执行计划、运行记录与源文件。'
},
kpis: [
{
label: '运行状态',
value: normalizeText(employee.status) || (statusValue === 'active' ? '运行中' : '未运行'),
unit: '',
meta:
normalizeText(meta.scheduleLabel) ||
normalizeText(employee.scope) ||
normalizeText(employee.executionMode) ||
'待配置执行计划',
trend: statusValue === 'active' ? 'up' : 'down',
color: resolveDigitalEmployeeStatusColor(statusValue)
}
]
}
}
export function buildEmployeeListItem(asset) {
const meta = buildDigitalEmployeeListMeta(asset)
const statusMeta = resolveStatusMeta(asset.status)
const displayName = meta.name || '数字员工技能'
const executionModeValue = meta.executionMode === '定时执行' ? 'timed' : 'manual'
const searchText = [
displayName,
meta.code,
meta.summary,
meta.owner,
meta.scope,
meta.executionMode,
meta.skillCategory,
statusMeta.label,
meta.enabledLabel
]
.map((value) => normalizeText(value).toLowerCase())
.filter(Boolean)
.join(' ')
return {
id: asset.id,
rawCode: asset.code,
short: displayName.slice(0, 2),
badgeTone: 'blue',
name: displayName,
code: meta.code,
summary: meta.summary,
owner: meta.owner,
scope: meta.scope,
executionMode: meta.executionMode,
executionModeValue,
skillCategory: meta.skillCategory,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
enabledLabel: meta.enabledLabel,
enabledTone: meta.enabledTone,
isEnabledValue: meta.enabled,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
updatedAtRaw: asset.updated_at || '',
digitalEmployee: meta,
searchText
}
}
export function buildEmployeePlaceholder(employee) {
return {
...employee,
type: 'digitalEmployees',
typeLabel: '数字员工',
currentVersion: employee.currentVersion || employee.version || '-',
workingVersion: employee.version || '-',
markdownContent: '',
loading: true
}
}
export function buildEmployeeDetail(asset) {
const meta = buildDigitalEmployeeDetailMeta({
...asset,
updated_at: formatDateTime(asset.updated_at)
})
const statusMeta = resolveStatusMeta(asset.status)
return {
id: asset.id,
type: 'digitalEmployees',
typeLabel: '数字员工',
rawCode: asset.code,
short: meta.name.slice(0, 2),
name: meta.name,
code: meta.code,
summary: meta.description,
owner: meta.owner,
reviewer: meta.reviewer,
category: meta.category,
scope: meta.scope,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
workingVersion: asset.working_version || asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
markdownContent: meta.sourceMarkdown,
digitalEmployee: meta,
loading: false
}
}
export function sortEmployees(items) {
return [...items].sort((left, right) =>
String(right.updatedAtRaw || '').localeCompare(String(left.updatedAtRaw || ''))
)
}
export function filterDigitalEmployees(items = [], filters = {}) {
const searchText = normalizeText(filters.keyword).toLowerCase()
const hasKeyword = Boolean(searchText)
const hasStatus = Boolean(filters.selectedStatus)
const hasEnabled = Boolean(filters.selectedEnabledState)
const hasExecutionMode = Boolean(filters.selectedExecutionMode)
return items.filter((item) => {
if (hasKeyword && !normalizeText(item.searchText).includes(searchText)) {
return false
}
if (hasStatus && item.statusValue !== filters.selectedStatus) {
return false
}
if (hasEnabled && (filters.selectedEnabledState === 'enabled') !== Boolean(item.isEnabledValue)) {
return false
}
if (hasExecutionMode && item.executionModeValue !== filters.selectedExecutionMode) {
return false
}
return true
})
}

View File

@@ -0,0 +1,214 @@
import { computed, ref } from 'vue'
import {
fetchAgentAssetDetail,
fetchAgentAssets,
fetchAgentRuns
} from '../../services/agentAssets.js'
import {
buildDetailViewModel,
buildListItem
} from './auditViewModel.js'
export function useAuditAssetData({
activeType,
activeMeta,
selectedSkill,
loadVersionTimeline,
loadSpreadsheetChangeRecords,
loadRiskRuleJson,
toast
}) {
const loading = ref(false)
const errorMessage = ref('')
const detailLoading = ref(false)
const detailError = ref('')
const runLoading = ref(false)
const runs = ref([])
const assetBuckets = ref({
financialRules: [],
riskRules: [],
mcp: []
})
const currentAssets = computed(() => assetBuckets.value[activeType.value] || [])
async function loadRuns(options = {}) {
if (runLoading.value && !options.force) {
return
}
runLoading.value = true
try {
const payload = await fetchAgentRuns({ limit: 50 })
runs.value = Array.isArray(payload) ? payload : []
} finally {
runLoading.value = false
}
}
async function loadAssets(options = {}) {
const shouldShowLoading = !options.silent && !options.background
if (shouldShowLoading) {
loading.value = true
}
if (!options.silent) {
errorMessage.value = ''
}
try {
const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType })
const items = Array.isArray(payload) ? payload.map(buildListItem).filter(Boolean) : []
if (activeMeta.value.assetType === 'rule') {
const nextBuckets = {
financialRules: [],
riskRules: []
}
items.forEach((item) => {
if (item?.tabId === 'financialRules' || item?.tabId === 'riskRules') {
nextBuckets[item.tabId].push(item)
}
})
assetBuckets.value = {
...assetBuckets.value,
...nextBuckets
}
} else {
assetBuckets.value = {
...assetBuckets.value,
[activeType.value]: items
}
}
} catch (error) {
if (options.silent || options.background) {
return
}
if (activeMeta.value.assetType === 'rule') {
assetBuckets.value = {
...assetBuckets.value,
financialRules:
activeType.value === 'financialRules' ? [] : assetBuckets.value.financialRules,
riskRules: []
}
} else {
assetBuckets.value = {
...assetBuckets.value,
[activeType.value]: []
}
}
errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
toast(errorMessage.value)
} finally {
if (shouldShowLoading) {
loading.value = false
}
}
}
async function refreshCurrentAssets() {
await loadAssets({ force: true, silent: true, background: true })
}
async function loadSelectedAssetDetail(assetId) {
detailLoading.value = true
detailError.value = ''
try {
if (!runs.value.length) {
await loadRuns()
}
const detail = await fetchAgentAssetDetail(assetId)
selectedSkill.value = buildDetailViewModel(detail, runs.value)
if (selectedSkill.value?.type !== 'rules') {
return
}
if (!selectedSkill.value.usesSpreadsheetRule && !selectedSkill.value.usesJsonRiskRule) {
loadVersionTimeline(assetId, { silent: true }).catch(() => {})
}
if (selectedSkill.value.usesSpreadsheetRule) {
loadSpreadsheetChangeRecords(assetId).catch(() => {})
}
if (!selectedSkill.value.usesJsonRiskRule) {
return
}
if (selectedSkill.value.riskRuleGenerationFailed || selectedSkill.value.riskRuleGenerationBusy) {
return
}
try {
await loadRiskRuleJson(assetId)
} catch (jsonError) {
console.warn('Failed to load risk rule JSON:', jsonError)
const jsonMessage =
jsonError?.message || '风险规则 JSON 文件缺失或无法读取,请同步规则库后重试。'
toast(jsonMessage)
selectedSkill.value = {
...selectedSkill.value,
riskRuleJsonText: '{}',
riskRuleDescription:
selectedSkill.value.riskRuleDescription ||
'规则 JSON 尚未就绪,请联系管理员执行平台风险规则同步。'
}
}
} catch (error) {
detailError.value = error?.message || '资产详情加载失败,请稍后重试。'
toast(detailError.value)
} finally {
detailLoading.value = false
}
}
function mergeSelectedRuleLifecycle(detail) {
if (!selectedSkill.value || !detail) {
return
}
const next = buildDetailViewModel(detail, runs.value)
selectedSkill.value = {
...selectedSkill.value,
status: next.status,
statusValue: next.statusValue,
statusTone: next.statusTone,
publishedVersion: next.publishedVersion,
workingVersion: next.workingVersion,
currentVersion: next.currentVersion,
displayVersion: next.displayVersion,
reviewer: next.reviewer,
publisher: next.publisher,
publishedAt: next.publishedAt,
isOnlineValue: next.isOnlineValue,
isOnlineLabel: next.isOnlineLabel,
isOnlineTone: next.isOnlineTone,
isEnabledValue: next.isEnabledValue,
isEnabledLabel: next.isEnabledLabel,
isEnabledTone: next.isEnabledTone,
latestTestSummary: next.latestTestSummary,
lastOperationLabel: next.lastOperationLabel,
lastOperationTone: next.lastOperationTone,
publishMeta: next.publishMeta,
publishState: next.publishState,
updatedAt: next.updatedAt,
configJson: next.configJson
}
}
return {
loading,
errorMessage,
detailLoading,
detailError,
runLoading,
runs,
assetBuckets,
currentAssets,
loadRuns,
loadAssets,
refreshCurrentAssets,
loadSelectedAssetDetail,
mergeSelectedRuleLifecycle
}
}

View File

@@ -0,0 +1,226 @@
import { ref } from 'vue'
import {
deleteAgentAsset,
fetchAgentAssetDetail,
publishRiskRuleAsset,
returnRiskRuleAsset,
setRiskRuleAssetEnabled
} from '../../services/agentAssets.js'
import { normalizeText } from './auditViewModel.js'
export function useAuditRiskRuleActions({
selectedSkill,
detailBusy,
actionState,
canOpenRiskRuleTest,
canDeleteRiskRule,
canReturnRiskRule,
canPublishRiskRule,
canToggleRiskRuleEnabled,
riskRuleTestPassed,
refreshCurrentAssets,
loadSelectedAssetDetail,
mergeSelectedRuleLifecycle,
closeDetail,
resolveActor,
toast
}) {
const riskRuleTestOpen = ref(false)
const riskRuleDeleteOpen = ref(false)
const riskRuleReturnOpen = ref(false)
const riskRulePublishOpen = ref(false)
const riskRuleReturnNote = ref('')
function resetRiskRuleActionDialogs() {
riskRuleTestOpen.value = false
riskRuleDeleteOpen.value = false
riskRuleReturnOpen.value = false
riskRulePublishOpen.value = false
riskRuleReturnNote.value = ''
}
function openRiskRuleTestDialog() {
if (detailBusy.value) {
return
}
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) {
const detail = await fetchAgentAssetDetail(selectedSkill.value.id)
mergeSelectedRuleLifecycle(detail)
}
}
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.isOnlineValue
actionState.value = 'toggle-risk-rule-enabled'
try {
const detail = await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
mergeSelectedRuleLifecycle(detail)
await refreshCurrentAssets()
toast(
nextEnabled
? '风险规则已上线。'
: '风险规则已下线,不会进入业务扫描。'
)
} catch (error) {
toast(error?.message || '风险规则上线状态更新失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
return {
riskRuleTestOpen,
riskRuleDeleteOpen,
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
resetRiskRuleActionDialogs,
openRiskRuleTestDialog,
closeRiskRuleTestDialog,
handleRiskRuleReportSaved,
openDeleteRiskRuleDialog,
closeDeleteRiskRuleDialog,
deleteSelectedRiskRule,
openReturnRiskRuleDialog,
closeReturnRiskRuleDialog,
returnSelectedRiskRule,
openPublishRiskRuleDialog,
closePublishRiskRuleDialog,
publishSelectedRiskRule,
toggleSelectedRiskRuleEnabled
}
}

View File

@@ -0,0 +1,133 @@
import { computed, ref } from 'vue'
import { generateRiskRuleAsset } from '../../services/agentAssets.js'
import { normalizeText } from './auditViewModel.js'
import {
createDefaultRiskRuleForm
} from './auditViewRiskRuleModel.js'
export function useAuditRiskRuleCreateFlow({
activeType,
isRuleManager,
detailBusy,
actionState,
assetBuckets,
refreshCurrentAssets,
resolveActor,
toast
}) {
const riskRuleCreateOpen = ref(false)
const riskRuleCreateForm = ref(createDefaultRiskRuleForm())
const riskRuleGenerationPollTimers = new Map()
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
const canCreateRiskRule = computed(
() => activeType.value === 'riskRules' && isRuleManager.value && !detailBusy.value
)
function openRiskRuleCreateDialog() {
if (activeType.value !== 'riskRules') {
return
}
riskRuleCreateForm.value = createDefaultRiskRuleForm()
riskRuleCreateOpen.value = true
}
function closeRiskRuleCreateDialog() {
if (riskRuleCreateBusy.value) {
return
}
riskRuleCreateOpen.value = false
}
async function submitRiskRuleCreate() {
if (!canCreateRiskRule.value || riskRuleCreateBusy.value) {
return
}
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
const ruleTitle = String(riskRuleCreateForm.value.rule_title || '').trim()
if (ruleTitle.length < 2) {
toast('请输入至少 2 个字的规则标题。')
return
}
if (naturalLanguage.length < 8) {
toast('请至少输入 8 个字的风险规则描述。')
return
}
actionState.value = 'generate-risk-rule'
try {
const detail = await generateRiskRuleAsset(
{
business_domain: 'expense',
business_stage: riskRuleCreateForm.value.business_stage,
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
natural_language: naturalLanguage
},
{ actor: resolveActor() }
)
riskRuleCreateOpen.value = false
await refreshCurrentAssets()
scheduleRiskRuleGenerationPoll(detail.id)
toast('风险规则已进入后台生成,列表会先显示生成中。')
} catch (error) {
toast(error?.message || '风险规则生成失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function stopRiskRuleGenerationPoll(assetId) {
const timer = riskRuleGenerationPollTimers.get(assetId)
if (timer) {
window.clearTimeout(timer)
riskRuleGenerationPollTimers.delete(assetId)
}
}
function scheduleRiskRuleGenerationPoll(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId) {
return
}
stopRiskRuleGenerationPoll(normalizedAssetId)
const timer = window.setTimeout(async () => {
try {
await refreshCurrentAssets()
const latest = (assetBuckets.value.riskRules || []).find((item) => item.id === normalizedAssetId)
if (!latest || latest.statusValue !== 'generating' || attempt >= 59) {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
return
}
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} catch {
if (attempt < 59) {
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} else {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
}
}
}, attempt === 0 ? 1200 : 3000)
riskRuleGenerationPollTimers.set(normalizedAssetId, timer)
}
function stopAllRiskRuleGenerationPolls() {
riskRuleGenerationPollTimers.forEach((timer) => window.clearTimeout(timer))
riskRuleGenerationPollTimers.clear()
}
return {
canCreateRiskRule,
riskRuleCreateOpen,
riskRuleCreateForm,
riskRuleCreateBusy,
openRiskRuleCreateDialog,
closeRiskRuleCreateDialog,
submitRiskRuleCreate,
stopAllRiskRuleGenerationPolls
}
}

View File

@@ -0,0 +1,101 @@
import {
fetchAgentAssetRuleJson,
saveAgentAssetRuleJson
} from '../../services/agentAssets.js'
import {
applyRiskRuleJsonState,
resolveRiskRuleDescription
} from './auditViewModel.js'
function readJsonPayload(payload) {
return payload?.payload && typeof payload.payload === 'object' ? payload.payload : payload
}
function downloadTextFile({ content, fileName, type }) {
const blob = new Blob([content], { type })
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = fileName
link.click()
URL.revokeObjectURL(objectUrl)
}
export function useAuditRiskRuleJsonEditor({
selectedSkill,
canEditMarkdown,
actionState,
toast
}) {
async function loadRiskRuleJson(assetId) {
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
return
}
const payload = await fetchAgentAssetRuleJson(assetId)
selectedSkill.value = applyRiskRuleJsonState(
selectedSkill.value,
readJsonPayload(payload),
payload
)
}
async function saveRiskRuleJson() {
if (!selectedSkill.value?.id || !canEditMarkdown.value) {
return
}
actionState.value = 'save-risk-json'
try {
const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}'))
const saved = await saveAgentAssetRuleJson(selectedSkill.value.id, { payload: parsed })
selectedSkill.value = applyRiskRuleJsonState(
selectedSkill.value,
readJsonPayload(saved),
saved
)
toast('风险规则 JSON 已保存。')
} catch (error) {
toast(error?.message || '风险规则 JSON 保存失败。')
} finally {
actionState.value = ''
}
}
function formatRiskRuleJson() {
if (!selectedSkill.value?.usesJsonRiskRule) {
return
}
try {
const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}'))
selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, parsed, {
name: selectedSkill.value.name,
description: resolveRiskRuleDescription(parsed)
})
} catch (error) {
toast(error?.message || 'JSON 格式无效,无法格式化。')
}
}
function downloadRiskRuleJson() {
if (!selectedSkill.value?.usesJsonRiskRule) {
return
}
downloadTextFile({
content: String(selectedSkill.value.riskRuleJsonText || '{}'),
fileName:
selectedSkill.value.ruleDocument?.file_name ||
`${selectedSkill.value.code || 'risk-rule'}.json`,
type: 'application/json;charset=utf-8'
})
}
return {
loadRiskRuleJson,
saveRiskRuleJson,
formatRiskRuleJson,
downloadRiskRuleJson
}
}

View File

@@ -0,0 +1,187 @@
import { computed, ref } from 'vue'
import { fetchEmployees } from '../../services/employees.js'
import { createAgentAssetReview } from '../../services/agentAssets.js'
import {
buildReviewNote
} from './auditViewRuntimeModel.js'
import {
normalizeText,
resolveReviewMeta
} from './auditViewModel.js'
export function useAuditRuleReviewFlow({
selectedSkill,
selectedSkillIsRule,
selectedSkillUsesJsonRisk,
canEditSelected,
canManageSelected,
isDisplayingWorkingVersion,
canOpenRiskRuleReviewSubmit,
riskRuleTestPassed,
detailBusy,
actionState,
refreshCurrentAssets,
loadSelectedAssetDetail,
resolveActor,
toast
}) {
const reviewSubmitOpen = ref(false)
const reviewSubmitVersion = ref('')
const reviewSubmitReviewer = ref('')
const reviewSubmitReviewerLoading = ref(false)
const reviewSubmitReviewerOptions = ref([])
const canSubmitReview = computed(
() =>
!selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
selectedSkillIsRule.value &&
isDisplayingWorkingVersion.value
)
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
const canReviewSelected = computed(
() => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
)
async function reviewSelectedRule(reviewStatus) {
if (!selectedSkill.value || !selectedSkillIsRule.value || detailBusy.value) {
return
}
if (reviewStatus === 'pending' && !canSubmitReview.value) {
return
}
if (reviewStatus !== 'pending' && !canReviewSelected.value) {
return
}
actionState.value = `review-${reviewStatus}`
try {
await createAgentAssetReview(
selectedSkill.value.id,
{
version: selectedSkill.value.workingVersion,
reviewer: resolveActor(),
review_status: reviewStatus,
review_note: buildReviewNote(reviewStatus)
},
{ actor: resolveActor() }
)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`当前规则版本已标记为${resolveReviewMeta(reviewStatus).label}`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function loadReviewSubmitReviewers() {
reviewSubmitReviewerLoading.value = true
try {
const employees = await fetchEmployees()
reviewSubmitReviewerOptions.value = (Array.isArray(employees) ? employees : [])
.filter(
(item) =>
item.status === '在职' &&
Array.isArray(item.roleCodes) &&
item.roleCodes.includes('manager')
)
.map((item) => ({
value: item.name,
label: `${item.name} · ${item.position || '高级管理员'}`
}))
} catch (error) {
reviewSubmitReviewerOptions.value = []
toast(error?.message || '审核人列表加载失败,请稍后重试。')
} finally {
reviewSubmitReviewerLoading.value = false
}
}
async function openSubmitReviewDialog() {
if (
selectedSkillUsesJsonRisk.value &&
!canOpenRiskRuleReviewSubmit.value
) {
return
}
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || ''
reviewSubmitReviewer.value = selectedSkill.value.reviewer || ''
reviewSubmitOpen.value = true
await loadReviewSubmitReviewers()
if (!reviewSubmitReviewerOptions.value.some((item) => item.value === reviewSubmitReviewer.value)) {
reviewSubmitReviewer.value = reviewSubmitReviewerOptions.value[0]?.value || ''
}
}
function closeSubmitReviewDialog() {
if (detailBusy.value) {
return
}
reviewSubmitOpen.value = false
}
async function submitSelectedRuleForReview() {
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) {
toast('请输入送审版本号。')
return
}
if (!reviewer) {
toast('请选择审核人。')
return
}
actionState.value = 'review-pending'
try {
await createAgentAssetReview(
selectedSkill.value.id,
{
version,
reviewer,
review_status: 'pending',
review_note: buildReviewNote('pending')
},
{ actor: resolveActor() }
)
reviewSubmitOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`规则版本 ${version} 已提交给 ${reviewer} 审核。`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
return {
reviewSubmitOpen,
reviewSubmitVersion,
reviewSubmitReviewer,
reviewSubmitReviewerLoading,
reviewSubmitReviewerOptions,
canSubmitReview,
hasReviewSubmitReviewers,
canReviewSelected,
reviewSelectedRule,
openSubmitReviewDialog,
closeSubmitReviewDialog,
submitSelectedRuleForReview
}
}

View File

@@ -0,0 +1,151 @@
import {
activateAgentAsset,
createAgentAssetVersion,
restoreAgentAssetVersion,
updateAgentAsset
} from '../../services/agentAssets.js'
import {
buildRuleConfigPayload,
incrementVersion
} from './auditViewRuntimeModel.js'
import {
buildMarkdownVersionContent,
normalizeText,
parseRuntimeRuleText
} from './auditViewModel.js'
export function useAuditRuleVersionActions({
selectedSkill,
selectedSkillIsRule,
canEditMarkdown,
canManageSelected,
actionState,
detailBusy,
refreshCurrentAssets,
loadSelectedAssetDetail,
resolveActor,
toast
}) {
async function persistRuleRuntimeConfig(asset, runtimeRule) {
await updateAgentAsset(
asset.id,
{
config_json: buildRuleConfigPayload(asset, runtimeRule)
},
{ actor: resolveActor() }
)
}
async function saveRuleVersion({ action, changeNote, successLabel }) {
if (
!selectedSkill.value ||
!selectedSkillIsRule.value ||
selectedSkill.value.usesSpreadsheetRule ||
!canEditMarkdown.value ||
detailBusy.value
) {
return
}
if (!normalizeText(selectedSkill.value.markdownContent)) {
toast('规则 Markdown 内容不能为空。')
return
}
const runtimeRule = parseRuntimeRuleText(selectedSkill.value.runtimeRuleText)
if (!runtimeRule) {
toast('运行时 JSON 必须是合法的对象。')
return
}
const nextVersion = incrementVersion(selectedSkill.value.currentVersion)
actionState.value = action
try {
await createAgentAssetVersion(
selectedSkill.value.id,
{
version: nextVersion,
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
content_type: 'markdown',
change_note: changeNote,
created_by: resolveActor()
},
{ actor: resolveActor() }
)
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`${successLabel} ${nextVersion}`)
} catch (error) {
toast(error?.message || `${successLabel}失败,请稍后重试。`)
} finally {
actionState.value = ''
}
}
async function saveRuleMarkdown() {
await saveRuleVersion({
action: 'save-markdown',
changeNote: '通过规则中心保存 Markdown 规则内容,并同步运行时 JSON。',
successLabel: '规则 Markdown 已保存为'
})
}
async function saveRuleRuntimeJson() {
await saveRuleVersion({
action: 'save-runtime-json',
changeNote: '通过规则中心保存运行时 JSON 配置。',
successLabel: '规则 JSON 已保存为'
})
}
async function activateSelectedRule() {
if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
return
}
actionState.value = 'activate'
try {
await activateAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('规则已正式上线。')
} catch (error) {
toast(error?.message || '规则上线失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function restoreSelectedVersion(version) {
if (
!selectedSkill.value ||
!selectedSkillIsRule.value ||
!canManageSelected.value ||
detailBusy.value ||
!version
) {
return
}
actionState.value = `restore-${version}`
try {
await restoreAgentAssetVersion(selectedSkill.value.id, version, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`已基于 ${version} 生成新的工作版本。`)
} catch (error) {
toast(error?.message || '历史版本恢复失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
return {
saveRuleMarkdown,
saveRuleRuntimeJson,
activateSelectedRule,
restoreSelectedVersion
}
}

View File

@@ -0,0 +1,423 @@
import { computed, nextTick, ref } from 'vue'
import {
fetchAgentAssetSpreadsheetBlob,
fetchAgentAssetSpreadsheetChangeRecords,
fetchAgentAssetSpreadsheetOnlyOfficeConfig,
importAgentAssetSpreadsheetContent
} from '../../services/agentAssets.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js'
import { buildSpreadsheetChangeRecordKey } from './auditViewRuntimeModel.js'
import {
formatDateTime,
formatSpreadsheetChangeSummary,
normalizeText,
resolveDiffChangeMeta
} from './auditViewModel.js'
export function useAuditSpreadsheetEditor({
selectedSkill,
selectedSkillUsesSpreadsheet,
canEditSpreadsheetInline,
canUploadSpreadsheet,
canDownloadSpreadsheet,
selectedSpreadsheetFileName,
actionState,
refreshCurrentAssets,
loadSelectedAssetDetail,
resolveActor,
toast
}) {
const spreadsheetUploadInput = ref(null)
const spreadsheetOnlyOfficeLoading = ref(false)
const spreadsheetOnlyOfficeError = ref('')
const spreadsheetOnlyOfficeEditor = ref(null)
const spreadsheetOnlyOfficeReady = ref(false)
const spreadsheetOnlyOfficeHostId = ref('audit-rule-onlyoffice')
const spreadsheetChangeRecordsByAsset = ref({})
const spreadsheetChangeDetailOpen = ref(false)
const selectedSpreadsheetChangeRecord = ref(null)
let spreadsheetOnlyOfficeMountSeq = 0
let spreadsheetOnlyOfficeLoadTimer = null
let spreadsheetOnlyOfficeHadLocalEdits = false
let spreadsheetOnlyOfficeSyncSeq = 0
let spreadsheetOnlyOfficeChangePollTimer = null
const selectedSpreadsheetChangeRecords = computed(() => {
if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id) {
return []
}
return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || [])
.filter((item) => item?.changed_at)
.map((item) => {
const sheetNames = [
...(Array.isArray(item.sheet_changes)
? item.sheet_changes.map((change) => normalizeText(change.sheet_name))
: []),
...(Array.isArray(item.cell_changes)
? item.cell_changes.map((change) => normalizeText(change.sheet_name))
: [])
].filter(Boolean)
const changedSheetNames = [...new Set(sheetNames)]
const previewChanges = Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : []
return {
...item,
time: formatDateTime(item.changed_at),
summary: formatSpreadsheetChangeSummary(item.summary),
changeCountLabel: item.changed_cell_count
? `${item.changed_cell_count} 处改动`
: `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`,
changedSheetNames,
sheetPreview: changedSheetNames.slice(0, 4),
remainingSheetCount: Math.max(changedSheetNames.length - 4, 0),
previewChanges,
remainingChangeCount: Math.max((item.changed_cell_count || 0) - previewChanges.length, 0)
}
})
})
const selectedSpreadsheetChangeSheetRows = computed(() =>
Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes)
? selectedSpreadsheetChangeRecord.value.sheet_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
const selectedSpreadsheetChangeCellRows = computed(() =>
Array.isArray(selectedSpreadsheetChangeRecord.value?.cell_changes)
? selectedSpreadsheetChangeRecord.value.cell_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
function stopSpreadsheetOnlyOfficeChangeSync() {
if (spreadsheetOnlyOfficeChangePollTimer) {
window.clearTimeout(spreadsheetOnlyOfficeChangePollTimer)
spreadsheetOnlyOfficeChangePollTimer = null
}
}
function destroySpreadsheetOnlyOfficeEditor() {
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
stopSpreadsheetOnlyOfficeChangeSync()
spreadsheetOnlyOfficeHadLocalEdits = false
spreadsheetOnlyOfficeSyncSeq += 1
if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) {
spreadsheetOnlyOfficeEditor.value.destroyEditor()
}
spreadsheetOnlyOfficeEditor.value = null
spreadsheetOnlyOfficeReady.value = false
}
function getLatestSpreadsheetChangeKey(assetId) {
return buildSpreadsheetChangeRecordKey(spreadsheetChangeRecordsByAsset.value[assetId] || [])
}
async function loadSpreadsheetChangeRecords(assetId) {
if (!assetId) {
return
}
const payload = await fetchAgentAssetSpreadsheetChangeRecords(assetId, 30)
spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value,
[assetId]: Array.isArray(payload) ? payload : []
}
}
async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId || selectedSkill.value?.id !== normalizedAssetId) {
return false
}
await loadSpreadsheetChangeRecords(normalizedAssetId)
const nextLatestKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
if (nextLatestKey && nextLatestKey !== previousLatestKey) {
return true
}
if (attempt >= 9) {
return false
}
await new Promise((resolve) => window.setTimeout(resolve, 800))
return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1)
}
function scheduleSpreadsheetOnlyOfficeChangeSync(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId) {
return
}
const syncSeq = ++spreadsheetOnlyOfficeSyncSeq
stopSpreadsheetOnlyOfficeChangeSync()
const previousLatestChangeKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
const runSync = async () => {
if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) {
return
}
try {
const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave(
normalizedAssetId,
previousLatestChangeKey
)
if (changeRecordRefreshed) {
await refreshCurrentAssets()
stopSpreadsheetOnlyOfficeChangeSync()
return
}
} catch {
// 临时轮询失败不打断编辑器,继续在窗口期内重试。
}
if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) {
return
}
if (attempt >= 29) {
return
}
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
scheduleSpreadsheetOnlyOfficeChangeSync(normalizedAssetId, attempt + 1)
}, 2000)
}
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
runSync().catch(() => {})
}, attempt === 0 ? 800 : 2000)
}
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId) {
return (
mountSeq !== spreadsheetOnlyOfficeMountSeq ||
!selectedSkillUsesSpreadsheet.value ||
selectedSkill.value?.id !== assetId ||
selectedSkill.value?.loading
)
}
async function mountSpreadsheetOnlyOfficeEditor(retryAttempt = 0) {
if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id || selectedSkill.value?.loading) {
destroySpreadsheetOnlyOfficeEditor()
return
}
const mountSeq = ++spreadsheetOnlyOfficeMountSeq
const assetId = selectedSkill.value.id
const editable = canEditSpreadsheetInline.value
spreadsheetOnlyOfficeLoading.value = true
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeReady.value = false
destroySpreadsheetOnlyOfficeEditor()
try {
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
await loadOnlyOfficeApi(payload.documentServerUrl)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (!window.DocsAPI?.DocEditor) {
throw new Error('表格编辑器未正确加载。')
}
// ONLYOFFICE 会改写宿主节点;每次挂载使用新 id 避免复用脏容器。
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${mountSeq}`
await nextTick()
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
const config = buildOnlyOfficeEditorConfig(payload.config, {
viewportHeight: window.innerHeight,
editable,
fillContainer: true
})
const upstreamEvents = config.events || {}
spreadsheetOnlyOfficeLoadTimer = window.setTimeout(() => {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (retryAttempt < 1) {
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeLoading.value = true
window.setTimeout(() => {
mountSpreadsheetOnlyOfficeEditor(retryAttempt + 1).catch(() => {})
}, 600)
return
}
spreadsheetOnlyOfficeError.value = '表格加载超时,请退出详情后重试。'
spreadsheetOnlyOfficeLoading.value = false
destroySpreadsheetOnlyOfficeEditor()
}, 15000)
config.events = {
...upstreamEvents,
onAppReady(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
spreadsheetOnlyOfficeReady.value = true
spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onAppReady?.(event)
},
onError(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
const errorCode = event?.data?.errorCode
const errorDescription = event?.data?.errorDescription
spreadsheetOnlyOfficeError.value = errorDescription
? `表格加载失败:${errorDescription}`
: `表格加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onError?.(event)
},
onDocumentStateChange(event) {
const hasChanges = Boolean(event?.data)
if (hasChanges) {
spreadsheetOnlyOfficeHadLocalEdits = true
if (!spreadsheetOnlyOfficeChangePollTimer) {
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
} else if (
spreadsheetOnlyOfficeHadLocalEdits &&
editable &&
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)
) {
spreadsheetOnlyOfficeHadLocalEdits = false
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
upstreamEvents.onDocumentStateChange?.(event)
}
}
spreadsheetOnlyOfficeEditor.value = new window.DocsAPI.DocEditor(
spreadsheetOnlyOfficeHostId.value,
config
)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
destroySpreadsheetOnlyOfficeEditor()
}
} catch (error) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。'
spreadsheetOnlyOfficeLoading.value = false
toast(spreadsheetOnlyOfficeError.value)
}
}
function triggerSpreadsheetUpload() {
if (!canUploadSpreadsheet.value) {
return
}
spreadsheetUploadInput.value?.click?.()
}
async function downloadSpreadsheetFile() {
if (!canDownloadSpreadsheet.value || !selectedSkill.value?.id) {
return
}
actionState.value = 'download-spreadsheet'
try {
const blob = await fetchAgentAssetSpreadsheetBlob(
selectedSkill.value.id,
'attachment'
)
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = selectedSpreadsheetFileName.value || '规则表.xlsx'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(objectUrl)
} catch (error) {
toast(error?.message || '规则表下载失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function uploadSpreadsheetFile(file) {
if (!file || !selectedSkill.value?.id || !canUploadSpreadsheet.value) {
return
}
actionState.value = 'upload-spreadsheet'
try {
await importAgentAssetSpreadsheetContent(selectedSkill.value.id, file, {
actor: resolveActor()
})
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSpreadsheetChangeRecords(selectedSkill.value.id)
toast(`已导入 ${file.name} 的表格内容,右侧会记录本次修改。`)
} catch (error) {
toast(error?.message || '规则表内容导入失败,请稍后重试。')
} finally {
actionState.value = ''
spreadsheetUploadInput.value?.reset?.()
}
}
async function handleSpreadsheetFileInput(event) {
await uploadSpreadsheetFile(event?.target?.files?.[0] || null)
}
function openSpreadsheetChangeDetail(item) {
if (!item?.changed_at) {
return
}
selectedSpreadsheetChangeRecord.value = item
spreadsheetChangeDetailOpen.value = true
}
function closeSpreadsheetChangeDetail() {
spreadsheetChangeDetailOpen.value = false
}
return {
spreadsheetUploadInput,
spreadsheetOnlyOfficeLoading,
spreadsheetOnlyOfficeError,
spreadsheetOnlyOfficeReady,
spreadsheetOnlyOfficeHostId,
spreadsheetChangeDetailOpen,
selectedSpreadsheetChangeRecord,
selectedSpreadsheetChangeRecords,
selectedSpreadsheetChangeSheetRows,
selectedSpreadsheetChangeCellRows,
destroySpreadsheetOnlyOfficeEditor,
mountSpreadsheetOnlyOfficeEditor,
triggerSpreadsheetUpload,
downloadSpreadsheetFile,
uploadSpreadsheetFile,
handleSpreadsheetFileInput,
loadSpreadsheetChangeRecords,
openSpreadsheetChangeDetail,
closeSpreadsheetChangeDetail
}
}

View File

@@ -0,0 +1,169 @@
import { computed, ref } from 'vue'
import { fetchAgentAssetVersionTimeline } from '../../services/agentAssets.js'
import {
buildDefaultRuntimeRule,
formatDateTime,
normalizeText,
resolveRuleTemplateLabel,
resolveTimelineEventMeta,
stringifyRuntimeRule
} from './auditViewModel.js'
const VERSION_TIMELINE_CACHE_TTL = 60 * 1000
function normalizeAssetId(assetId) {
return normalizeText(assetId)
}
function readVersionTimelineCache(timelineCache, assetId) {
const key = normalizeAssetId(assetId)
if (!key) {
return null
}
const cached = timelineCache.get(key)
if (!cached) {
return null
}
const isExpired = Date.now() - cached.timestamp > VERSION_TIMELINE_CACHE_TTL
return isExpired ? null : cached.items
}
function writeVersionTimelineCache(timelineCache, assetId, items) {
const key = normalizeAssetId(assetId)
if (!key) {
return
}
timelineCache.set(key, {
items,
timestamp: Date.now()
})
}
function applyVersionPayloadToRulePreview(skill, version) {
if (!skill || !version) {
return
}
const selectedVersion = version.version
skill.displayVersion = selectedVersion
skill.displayVersionChangeNote = version.note || '无版本说明'
if (skill.usesSpreadsheetRule) {
return
}
if (typeof version.markdownContent === 'string') {
skill.markdownContent = version.markdownContent
}
const runtimeRule = version.runtimeRule || buildDefaultRuntimeRule(skill)
skill.runtimeRuleText = stringifyRuntimeRule(runtimeRule)
skill.runtimeKind = normalizeText(runtimeRule.kind) || skill.runtimeKind || 'policy_rule_draft'
skill.ruleTemplateKey = normalizeText(runtimeRule.template_key) || skill.ruleTemplateKey
skill.ruleTemplateLabel = resolveRuleTemplateLabel(skill.ruleTemplateKey)
}
export function useAuditVersionTimeline({ selectedSkill, toast }) {
const versionSwitchTarget = ref(null)
const versionTimelineOpen = ref(false)
const versionTimelineLoading = ref(false)
const versionTimelineError = ref('')
const versionTimelineItems = ref([])
const versionTimelineCache = new Map()
const selectedVersionTimelineItems = computed(() =>
versionTimelineItems.value.map((item) => ({
...item,
meta: resolveTimelineEventMeta(item.event_type),
timeLabel: formatDateTime(item.event_time)
}))
)
async function loadVersionTimeline(assetId = selectedSkill.value?.id, options = {}) {
if (!assetId) {
return
}
const cachedItems = options.force ? null : readVersionTimelineCache(versionTimelineCache, assetId)
if (cachedItems) {
versionTimelineItems.value = cachedItems
return
}
versionTimelineLoading.value = true
versionTimelineError.value = ''
try {
const payload = await fetchAgentAssetVersionTimeline(assetId)
const nextItems = Array.isArray(payload) ? payload : []
versionTimelineItems.value = nextItems
writeVersionTimelineCache(versionTimelineCache, assetId, nextItems)
} catch (error) {
versionTimelineError.value = error?.message || '操作记录加载失败,请稍后重试。'
if (!options.silent) {
toast(versionTimelineError.value)
}
versionTimelineItems.value = []
writeVersionTimelineCache(versionTimelineCache, assetId, [])
} finally {
versionTimelineLoading.value = false
}
}
async function openVersionTimeline() {
if (!selectedSkill.value?.id) {
return
}
versionTimelineOpen.value = true
await loadVersionTimeline(selectedSkill.value.id)
}
function closeVersionTimeline() {
versionTimelineOpen.value = false
}
function clearVersionTimelineState() {
versionTimelineOpen.value = false
versionTimelineItems.value = []
versionTimelineError.value = ''
versionSwitchTarget.value = null
}
function openVersionSwitch(version) {
if (!selectedSkill.value || version.version === selectedSkill.value.displayVersion) {
return
}
versionSwitchTarget.value = version
}
function cancelVersionSwitch() {
versionSwitchTarget.value = null
}
function confirmVersionSwitch() {
if (!selectedSkill.value || !versionSwitchTarget.value) {
return
}
applyVersionPayloadToRulePreview(selectedSkill.value, versionSwitchTarget.value)
versionSwitchTarget.value = null
}
return {
versionSwitchTarget,
versionTimelineOpen,
versionTimelineLoading,
versionTimelineError,
selectedVersionTimelineItems,
loadVersionTimeline,
openVersionTimeline,
closeVersionTimeline,
clearVersionTimelineState,
openVersionSwitch,
cancelVersionSwitch,
confirmVersionSwitch
}
}

View File

@@ -1046,5 +1046,29 @@ export default defineConfig({
}
}
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return undefined
}
if (id.includes('element-plus') || id.includes('@element-plus')) {
return 'vendor-element-plus'
}
if (id.includes('echarts') || id.includes('zrender')) {
return 'vendor-echarts'
}
if (id.includes('@vueuse')) {
return 'vendor-vueuse'
}
if (id.includes('primeicons') || id.includes('primevue')) {
return 'vendor-prime'
}
return 'vendor'
}
}
}
},
plugins: [vue(), localSetupPlugin()]
})