Files
X-Financial/web/src/views/scripts/AuditView.js

1492 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { fetchEmployees } from '../../services/employees.js'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
activateAgentAsset,
createAgentAssetReview,
createAgentAssetVersion,
fetchAgentAssetDetail,
fetchAgentAssets,
fetchAgentAssetSpreadsheetBlob,
fetchAgentAssetSpreadsheetChangeRecords,
fetchAgentAssetSpreadsheetOnlyOfficeConfig,
fetchAgentAssetRuleJson,
fetchAgentAssetVersionTimeline,
fetchAgentRuns,
saveAgentAssetRuleJson,
importAgentAssetSpreadsheetContent,
restoreAgentAssetVersion,
updateAgentAsset
} from '../../services/agentAssets.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isFinanceUser, isManagerUser } from '../../utils/accessControl.js'
import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js'
import {
buildReviewNote,
buildRuleConfigPayload,
buildSpreadsheetChangeRecordKey,
filterAuditAssets,
incrementVersion
} from './auditViewRuntimeModel.js'
import {
TAB_META,
STATUS_OPTIONS,
RISK_SCENARIO_OPTIONS,
normalizeText,
readConfigJson,
resolveRuleTemplateLabel,
resolveTimelineEventMeta,
formatDateTime,
formatSpreadsheetChangeSummary,
resolveDiffChangeMeta,
resolveDomainLabel,
resolveStatusMeta,
resolveReviewMeta,
buildListItem,
buildDetailViewModel,
applyRiskRuleJsonState,
resolveRiskRuleDescription,
buildPreviewRuleDetail,
buildDefaultRuntimeRule,
stringifyRuntimeRule,
parseRuntimeRuleText,
buildMarkdownVersionContent
} from './auditViewModel.js'
export default {
name: 'AuditView',
components: {
ConfirmDialog,
TableLoadingState,
TableEmptyState
},
emits: ['detail-open-change'],
setup(_, { emit }) {
const { toast } = useToast()
const { currentUser } = useSystemState()
const tabs = Object.entries(TAB_META).map(([id, meta]) => ({
id,
label: meta.label
}))
const activeType = ref('financialRules')
const selectedSkill = ref(null)
const versionSwitchTarget = ref(null)
const keyword = ref('')
const activeFilterPopover = ref('')
const selectedDomain = ref('')
const selectedOwner = ref('')
const selectedStatus = ref('')
const selectedRiskScenario = ref('')
const loading = ref(false)
const errorMessage = ref('')
const detailLoading = ref(false)
const detailError = ref('')
const actionState = ref('')
const reviewSubmitOpen = ref(false)
const reviewSubmitVersion = ref('')
const reviewSubmitReviewer = ref('')
const reviewSubmitReviewerLoading = ref(false)
const reviewSubmitReviewerOptions = ref([])
const runLoading = ref(false)
const runs = ref([])
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 versionTimelineOpen = ref(false)
const versionTimelineLoading = ref(false)
const versionTimelineError = ref('')
const versionTimelineItems = ref([])
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 assetBuckets = ref({
financialRules: [],
riskRules: [],
skills: [],
mcp: [],
tasks: []
})
const isAdmin = computed(() => isManagerUser(currentUser.value))
const isFinance = computed(() => isFinanceUser(currentUser.value))
const activeMeta = computed(() => TAB_META[activeType.value])
const activeTabLabel = computed(() => activeMeta.value.label)
const currentAssets = computed(() => assetBuckets.value[activeType.value] || [])
const searchPlaceholder = computed(() => activeMeta.value.searchPlaceholder)
const createButtonLabel = computed(() => activeMeta.value.createButtonLabel)
const hintText = computed(() => activeMeta.value.hintText)
const tableColumns = computed(() => activeMeta.value.tableColumns)
const showRuntimeColumn = computed(() => activeMeta.value.showRuntimeColumn !== false)
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
)
const selectedSkillUsesJsonRisk = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
)
const canManageSelected = computed(
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canEditSelected = computed(
() =>
Boolean(selectedSkill.value) &&
!selectedSkill.value?.isPreviewMock &&
(isAdmin.value || isFinance.value)
)
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
const isDisplayingWorkingVersion = computed(
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
)
const canSubmitReview = computed(
() => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
)
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
const canReviewSelected = computed(
() => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
)
const canUploadSpreadsheet = computed(
() =>
canEditSelected.value &&
selectedSkillUsesSpreadsheet.value &&
!detailBusy.value
)
const canDownloadSpreadsheet = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
Boolean(selectedSkill.value?.id) &&
!detailBusy.value
)
const canEditSpreadsheetInline = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
(selectedSkill.value?.isPreviewMock || canEditSelected.value)
)
const selectedSpreadsheetFileName = computed(
() =>
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
)
const selectedSpreadsheetModeLabel = computed(() => {
if (selectedSkill.value?.isPreviewMock) {
return canEditSpreadsheetInline.value ? '可编辑' : '只读'
}
return canEditSpreadsheetInline.value ? '在线可编辑' : '只读'
})
const selectedVersionTimelineItems = computed(() =>
versionTimelineItems.value.map((item) => ({
...item,
meta: resolveTimelineEventMeta(item.event_type),
timeLabel: formatDateTime(item.event_time)
}))
)
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)
}))
: []
)
const detailBusy = computed(() => Boolean(actionState.value))
const showReviewNote = computed(
() => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel)
)
const domainOptions = computed(() => {
const uniqueValues = [...new Set(currentAssets.value.map((item) => item.domainValue).filter(Boolean))]
return [
{ value: '', label: '全部业务域' },
...uniqueValues.map((value) => ({
value,
label: resolveDomainLabel(value)
}))
]
})
const ownerOptions = computed(() => {
const uniqueOwners = [...new Set(currentAssets.value.map((item) => item.owner).filter(Boolean))]
return [
{ value: '', label: '全部负责人' },
...uniqueOwners.map((value) => ({
value,
label: value
}))
]
})
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 selectedStatusLabel = computed(
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
)
const showRiskScenarioFilter = computed(() =>
['financialRules', 'riskRules'].includes(activeType.value)
)
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
const selectedRiskScenarioLabel = computed(
() =>
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.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 (selectedOwner.value) {
tokens.push(`负责人:${selectedOwner.value}`)
}
if (keyword.value.trim()) {
tokens.push(`搜索:${keyword.value.trim()}`)
}
return tokens
})
const auditEmptyState = computed(() => {
const hasFilters = activeFilterTokens.value.length > 0
const supportedFilters = [
'业务域',
'负责人',
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
...(showStatusFilter.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 ? 'emerald' : 'slate',
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
tips: hasFilters
? [
`${supportedFilters.join('、')}会叠加过滤`,
showRiskScenarioFilter.value
? '可以换个规则名称或场景分类继续搜索'
: '可以换个编码、名称或负责人关键词继续搜索'
]
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
}
})
const canActivateSelected = computed(() => {
if (!selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
return false
}
return (
isDisplayingWorkingVersion.value &&
selectedSkill.value?.reviewStatusValue === 'approved' &&
selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
)
})
const activateBlockedReason = computed(() => {
if (!selectedSkillIsRule.value) {
return ''
}
if (selectedSkill.value?.isPreviewMock) {
return '当前为页面预览态,暂不执行真实审核和上线。'
}
if (!canManageSelected.value) {
return '仅高级管理人员可执行审核和上线。'
}
if (!isDisplayingWorkingVersion.value) {
return '请先切回当前工作版本,再执行审核或上线。'
}
if (selectedSkill.value?.workingVersion === selectedSkill.value?.publishedVersion) {
return '当前工作版本已经是线上版本。'
}
if (selectedSkill.value?.reviewStatusValue !== 'approved') {
return '当前规则版本未审核通过,不能上线。'
}
return ''
})
const visibleSkills = computed(() =>
filterAuditAssets(currentAssets.value, {
keyword: keyword.value,
selectedDomain: selectedDomain.value,
selectedOwner: selectedOwner.value,
selectedStatus: selectedStatus.value,
selectedRiskScenario: selectedRiskScenario.value,
showStatusFilter: showStatusFilter.value,
showRiskScenarioFilter: showRiskScenarioFilter.value
})
)
watch(
selectedSkill,
(value) => {
emit('detail-open-change', Boolean(value))
},
{ immediate: true }
)
watch(
() => [
selectedSkill.value?.id || '',
selectedSkill.value?.loading ? '1' : '0',
selectedSkill.value?.usesSpreadsheetRule ? '1' : '0'
],
async () => {
if (!selectedSkillUsesSpreadsheet.value || selectedSkill.value?.loading) {
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
return
}
await mountSpreadsheetOnlyOfficeEditor()
}
)
watch(activeType, () => {
destroySpreadsheetOnlyOfficeEditor()
selectedSkill.value = null
versionSwitchTarget.value = null
resetFilters()
loadAssets({ force: true }).catch((error) => {
errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
})
})
function resetFilters() {
keyword.value = ''
selectedDomain.value = ''
selectedOwner.value = ''
selectedStatus.value = ''
selectedRiskScenario.value = ''
activeFilterPopover.value = ''
}
function handleAuditEmptyAction() {
if (!currentAssets.value.length || !activeFilterTokens.value.length) {
loadAssets({ force: true }).catch(() => {})
return
}
resetFilters()
}
function toggleFilterPopover(name) {
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function selectFilter(name, value) {
if (name === 'domain') {
selectedDomain.value = value
}
if (name === 'owner') {
selectedOwner.value = value
}
if (name === 'status') {
selectedStatus.value = value
}
if (name === 'riskScenario') {
selectedRiskScenario.value = value
}
closeFilterPopover()
}
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
closeFilterPopover()
return
}
if (!target.closest('.picker-filter')) {
closeFilterPopover()
}
}
function resolveActor() {
return currentUser.value?.name || currentUser.value?.username || 'system'
}
async function persistRuleRuntimeConfig(asset, runtimeRule) {
await updateAgentAsset(
asset.id,
{
config_json: buildRuleConfigPayload(asset, runtimeRule)
},
{ actor: resolveActor() }
)
}
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 stopSpreadsheetOnlyOfficeChangeSync() {
if (spreadsheetOnlyOfficeChangePollTimer) {
window.clearTimeout(spreadsheetOnlyOfficeChangePollTimer)
spreadsheetOnlyOfficeChangePollTimer = null
}
}
function getLatestSpreadsheetChangeKey(assetId) {
return buildSpreadsheetChangeRecordKey(spreadsheetChangeRecordsByAsset.value[assetId] || [])
}
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 {
// Ignore transient polling failures and continue retrying within the window.
}
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('表格编辑器未正确加载。')
}
// Host id must be unique for every mount. ONLYOFFICE mutates its host DOM
// during lifecycle teardown; reusing the same element can leave the next
// DocEditor instance with a dead container even though config loading succeeds.
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 = ''
if (spreadsheetUploadInput.value) {
spreadsheetUploadInput.value.value = ''
}
}
}
async function handleSpreadsheetFileInput(event) {
await uploadSpreadsheetFile(event?.target?.files?.[0] || null)
}
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 = {}) {
loading.value = true
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 (activeMeta.value.assetType === 'rule') {
assetBuckets.value = {
...assetBuckets.value,
financialRules:
activeType.value === 'financialRules' ? [] : assetBuckets.value.financialRules,
riskRules: []
}
errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
} else {
assetBuckets.value = {
...assetBuckets.value,
[activeType.value]: []
}
errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
}
if (!options.silent) {
toast(errorMessage.value)
}
} finally {
loading.value = false
}
}
async function refreshCurrentAssets() {
await loadAssets({ force: true, silent: 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') {
if (!selectedSkill.value.usesSpreadsheetRule && !selectedSkill.value.usesJsonRiskRule) {
loadVersionTimeline(assetId, { silent: true }).catch(() => {})
}
if (selectedSkill.value.usesSpreadsheetRule) {
loadSpreadsheetChangeRecords(assetId).catch(() => {})
}
if (selectedSkill.value.usesJsonRiskRule) {
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
}
}
async function loadRiskRuleJson(assetId) {
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
return
}
const payload = await fetchAgentAssetRuleJson(assetId)
const rulePayload = payload?.payload && typeof payload.payload === 'object' ? payload.payload : payload
selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, rulePayload, payload)
}
async function saveRiskRuleJson() {
if (!selectedSkill.value?.id || !canEditMarkdown.value) {
return
}
actionState.value = 'save-risk-json'
detailBusy.value = true
try {
const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}'))
const saved = await saveAgentAssetRuleJson(selectedSkill.value.id, { payload: parsed })
const rulePayload = saved?.payload && typeof saved.payload === 'object' ? saved.payload : saved
selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, rulePayload, saved)
toast('风险规则 JSON 已保存。')
} catch (error) {
toast(error?.message || '风险规则 JSON 保存失败。')
} finally {
detailBusy.value = false
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
}
const blob = new Blob([String(selectedSkill.value.riskRuleJsonText || '{}')], {
type: 'application/json;charset=utf-8'
})
const fileName =
selectedSkill.value.ruleDocument?.file_name ||
`${selectedSkill.value.code || 'risk-rule'}.json`
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
link.click()
URL.revokeObjectURL(link.href)
}
async function loadSpreadsheetChangeRecords(assetId) {
if (!assetId) {
return
}
const payload = await fetchAgentAssetSpreadsheetChangeRecords(assetId, 30)
spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value,
[assetId]: Array.isArray(payload) ? payload : []
}
}
function openSpreadsheetChangeDetail(item) {
if (!item?.changed_at) {
return
}
selectedSpreadsheetChangeRecord.value = item
spreadsheetChangeDetailOpen.value = true
}
function closeSpreadsheetChangeDetail() {
spreadsheetChangeDetailOpen.value = false
}
function openAssetDetail(asset) {
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
if (asset?.isPreviewMock) {
selectedSkill.value = buildPreviewRuleDetail()
detailError.value = ''
detailLoading.value = false
versionSwitchTarget.value = null
return
}
const opensSpreadsheetRule = Boolean(asset?.usesSpreadsheetRule)
selectedSkill.value = {
...asset,
configJson: {},
isPreviewMock: false,
usesSpreadsheetRule: opensSpreadsheetRule,
usesJsonRiskRule: Boolean(asset?.usesJsonRiskRule),
riskRuleJsonText: '{}',
riskRuleSummary: null,
riskRuleDescription: '',
riskRuleSourceRef: '',
ruleDocument: asset?.ruleDocument || null,
scenarioList: [],
fields: [],
promptSections: [],
outputRules: [],
tests: [],
triggers: [],
tools: [],
history: [],
markdownContent: '',
runtimeRuleText: '',
ruleTemplateKey: '',
ruleTemplateLabel: '',
runtimeKind: 'policy_rule_draft',
displayVersion: asset.version,
displayVersionChangeNote: '无版本说明',
loading: !opensSpreadsheetRule,
reviewStatusLabel: opensSpreadsheetRule ? '' : '加载中',
reviewStatusTone: 'draft'
}
versionSwitchTarget.value = null
if (opensSpreadsheetRule) {
loadSpreadsheetChangeRecords(asset.id).catch(() => {})
}
loadSelectedAssetDetail(asset.id).catch(() => {})
}
function closeDetail() {
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
selectedSkill.value = null
detailError.value = ''
detailLoading.value = false
versionSwitchTarget.value = null
versionTimelineOpen.value = false
versionTimelineItems.value = []
}
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
}
selectedSkill.value.displayVersion = versionSwitchTarget.value.version
selectedSkill.value.displayVersionChangeNote = versionSwitchTarget.value.note || '无版本说明'
if (selectedSkill.value.usesSpreadsheetRule) {
versionSwitchTarget.value = null
return
}
if (typeof versionSwitchTarget.value.markdownContent === 'string') {
selectedSkill.value.markdownContent = versionSwitchTarget.value.markdownContent
}
const runtimeRule = versionSwitchTarget.value.runtimeRule || buildDefaultRuntimeRule(selectedSkill.value)
selectedSkill.value.runtimeRuleText = stringifyRuntimeRule(runtimeRule)
selectedSkill.value.runtimeKind =
normalizeText(runtimeRule.kind) || selectedSkill.value.runtimeKind || 'policy_rule_draft'
selectedSkill.value.ruleTemplateKey =
normalizeText(runtimeRule.template_key) || selectedSkill.value.ruleTemplateKey
selectedSkill.value.ruleTemplateLabel = resolveRuleTemplateLabel(selectedSkill.value.ruleTemplateKey)
versionSwitchTarget.value = null
}
async function saveRuleMarkdown() {
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 = 'save-markdown'
try {
await createAgentAssetVersion(
selectedSkill.value.id,
{
version: nextVersion,
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
content_type: 'markdown',
change_note: '通过任务规则中心保存 Markdown 规则内容,并同步运行时 JSON。',
created_by: resolveActor()
},
{ actor: resolveActor() }
)
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`规则 Markdown 已保存为 ${nextVersion}`)
} catch (error) {
toast(error?.message || '规则 Markdown 保存失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function saveRuleRuntimeJson() {
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 = 'save-runtime-json'
try {
await createAgentAssetVersion(
selectedSkill.value.id,
{
version: nextVersion,
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
content_type: 'markdown',
change_note: '通过任务规则中心保存运行时 JSON 配置。',
created_by: resolveActor()
},
{ actor: resolveActor() }
)
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`规则 JSON 已保存为 ${nextVersion}`)
} catch (error) {
toast(error?.message || '规则 JSON 保存失败,请稍后重试。')
} finally {
actionState.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 (!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
}
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 = ''
}
}
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 = ''
}
}
async function loadVersionTimeline(assetId = selectedSkill.value?.id, options = {}) {
if (!assetId) {
return
}
versionTimelineLoading.value = true
versionTimelineError.value = ''
try {
const payload = await fetchAgentAssetVersionTimeline(assetId)
versionTimelineItems.value = Array.isArray(payload) ? payload : []
} catch (error) {
versionTimelineError.value = error?.message || '操作记录加载失败,请稍后重试。'
if (!options.silent) {
toast(versionTimelineError.value)
}
} 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
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
loadAssets({ force: true }).catch(() => {})
loadRuns().catch(() => {})
})
onBeforeUnmount(() => {
destroySpreadsheetOnlyOfficeEditor()
document.removeEventListener('click', handleDocumentClick)
})
return {
tabs,
activeType,
activeTabLabel,
selectedSkill,
versionSwitchTarget,
keyword,
createButtonLabel,
hintText,
searchPlaceholder,
tableColumns,
showRuntimeColumn,
showVersionColumn,
showMetricColumn,
showStatusColumn,
visibleSkills,
auditEmptyState,
loading,
errorMessage,
detailLoading,
detailError,
selectedDomain,
selectedOwner,
selectedStatus,
selectedRiskScenario,
selectedDomainLabel,
selectedOwnerLabel,
selectedStatusLabel,
selectedRiskScenarioLabel,
showRiskScenarioFilter,
showStatusFilter,
domainOptions,
ownerOptions,
statusOptions: STATUS_OPTIONS,
riskScenarioOptions: RISK_SCENARIO_OPTIONS,
activeFilterPopover,
activeFilterTokens,
canManageSelected,
canEditSelected,
canSubmitReview,
hasReviewSubmitReviewers,
canReviewSelected,
canEditMarkdown,
canUploadSpreadsheet,
canDownloadSpreadsheet,
canEditSpreadsheetInline,
canActivateSelected,
activateBlockedReason,
selectedSkillIsRule,
selectedSkillUsesSpreadsheet,
selectedSkillUsesJsonRisk,
selectedSpreadsheetFileName,
selectedSpreadsheetModeLabel,
selectedVersionTimelineItems,
selectedSpreadsheetChangeRecords,
detailBusy,
actionState,
reviewSubmitOpen,
reviewSubmitVersion,
reviewSubmitReviewer,
reviewSubmitReviewerLoading,
reviewSubmitReviewerOptions,
showReviewNote,
spreadsheetUploadInput,
spreadsheetOnlyOfficeLoading,
spreadsheetOnlyOfficeError,
spreadsheetOnlyOfficeReady,
spreadsheetOnlyOfficeHostId,
versionTimelineOpen,
versionTimelineLoading,
versionTimelineError,
spreadsheetChangeDetailOpen,
selectedSpreadsheetChangeRecord,
selectedSpreadsheetChangeSheetRows,
selectedSpreadsheetChangeCellRows,
openAssetDetail,
closeDetail,
resetFilters,
handleAuditEmptyAction,
toggleFilterPopover,
selectFilter,
closeFilterPopover,
openVersionSwitch,
cancelVersionSwitch,
confirmVersionSwitch,
saveRuleMarkdown,
saveRuleRuntimeJson,
saveRiskRuleJson,
formatRiskRuleJson,
downloadRiskRuleJson,
triggerSpreadsheetUpload,
downloadSpreadsheetFile,
handleSpreadsheetFileInput,
reviewSelectedRule,
openSubmitReviewDialog,
closeSubmitReviewDialog,
submitSelectedRuleForReview,
activateSelectedRule,
restoreSelectedVersion,
openVersionTimeline,
closeVersionTimeline,
openSpreadsheetChangeDetail,
closeSpreadsheetChangeDetail,
loadAssets
}
}
}