424 lines
14 KiB
JavaScript
424 lines
14 KiB
JavaScript
|
|
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
|
|||
|
|
}
|
|||
|
|
}
|