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

1492 lines
51 KiB
JavaScript
Raw Normal View History

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
}
}
}