feat(ui): finalize shared shells and loading states
This commit is contained in:
@@ -42,11 +42,12 @@ export function useAppShell() {
|
||||
filters,
|
||||
ranges,
|
||||
activeRange,
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
reload: reloadRequests
|
||||
} = useRequests()
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
ensureLoaded: ensureRequestsLoaded,
|
||||
reload: reloadRequests
|
||||
} = useRequests()
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -80,26 +81,22 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const detailMode = computed(() => route.name === 'app-document-detail')
|
||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||
|
||||
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
|
||||
const workbenchActive = computed(() => activeView.value === 'workbench')
|
||||
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
|
||||
|
||||
watch(documentsListActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
}
|
||||
})
|
||||
|
||||
watch(workbenchActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
}
|
||||
})
|
||||
watch(
|
||||
requestsNeeded,
|
||||
(isNeeded) => {
|
||||
if (isNeeded) {
|
||||
void ensureRequestsLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const workbenchSummary = computed(() =>
|
||||
buildWorkbenchSummary(requests.value, currentUser.value)
|
||||
@@ -118,15 +115,8 @@ export function useAppShell() {
|
||||
}
|
||||
}
|
||||
|
||||
if (logDetailMode.value) {
|
||||
return {
|
||||
title: '日志详情',
|
||||
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
|
||||
}
|
||||
}
|
||||
|
||||
return currentView.value
|
||||
})
|
||||
return currentView.value
|
||||
})
|
||||
|
||||
const requestSummary = computed(() =>
|
||||
filteredRequests.value.reduce(
|
||||
@@ -351,10 +341,9 @@ export function useAppShell() {
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
currentView,
|
||||
customRange,
|
||||
detailMode,
|
||||
logDetailMode,
|
||||
filteredRequests,
|
||||
customRange,
|
||||
detailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
|
||||
@@ -18,7 +18,10 @@ const SOURCE_PRIORITY = {
|
||||
const documentRows = ref([])
|
||||
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||
const loading = ref(false)
|
||||
const INBOX_CACHE_TTL_MS = 30000
|
||||
let refreshTimer = null
|
||||
let refreshPromise = null
|
||||
let lastRefreshAt = 0
|
||||
let viewedKeysListenerAttached = false
|
||||
|
||||
function normalizeClaimText(...values) {
|
||||
@@ -125,10 +128,22 @@ export function useDocumentCenterInbox() {
|
||||
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
||||
const hasUnread = computed(() => unreadCount.value > 0)
|
||||
|
||||
async function refreshDocumentInbox() {
|
||||
async function refreshDocumentInbox(options = {}) {
|
||||
const force = Boolean(options.force)
|
||||
const now = Date.now()
|
||||
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
if (!force && lastRefreshAt && now - lastRefreshAt < INBOX_CACHE_TTL_MS) {
|
||||
refreshViewedDocumentKeys()
|
||||
return documentRows.value
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
refreshPromise = (async () => {
|
||||
const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([
|
||||
readClaimList(fetchExpenseClaims),
|
||||
readClaimList(fetchApprovalExpenseClaims),
|
||||
@@ -140,13 +155,21 @@ export function useDocumentCenterInbox() {
|
||||
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
|
||||
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
|
||||
})
|
||||
lastRefreshAt = Date.now()
|
||||
refreshViewedDocumentKeys()
|
||||
|
||||
return documentRows.value
|
||||
})()
|
||||
|
||||
try {
|
||||
return await refreshPromise
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function startDocumentInboxPolling(intervalMs = 45000) {
|
||||
function startDocumentInboxPolling(intervalMs = 120000) {
|
||||
stopDocumentInboxPolling()
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
78
web/src/composables/useMinimumVisibleState.js
Normal file
78
web/src/composables/useMinimumVisibleState.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { computed, getCurrentScope, onScopeDispose, ref, unref, watch } from 'vue'
|
||||
|
||||
export const DEFAULT_MIN_VISIBLE_MS = 650
|
||||
|
||||
function resolveBooleanSource(source) {
|
||||
return Boolean(typeof source === 'function' ? source() : unref(source))
|
||||
}
|
||||
|
||||
function resolveMinVisibleMs(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) && nextValue >= 0 ? nextValue : DEFAULT_MIN_VISIBLE_MS
|
||||
}
|
||||
|
||||
export function useMinimumVisibleState(source, options = {}) {
|
||||
const minVisibleMs = computed(() => resolveMinVisibleMs(unref(options.minVisibleMs)))
|
||||
const visible = ref(resolveBooleanSource(source))
|
||||
let visibleStartedAt = visible.value ? Date.now() : 0
|
||||
let hideTimer = null
|
||||
|
||||
function clearHideTimer() {
|
||||
if (!hideTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
|
||||
function show() {
|
||||
clearHideTimer()
|
||||
|
||||
if (!visible.value) {
|
||||
visibleStartedAt = Date.now()
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
clearHideTimer()
|
||||
|
||||
if (!visible.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const remainingMs = Math.max(0, minVisibleMs.value - (Date.now() - visibleStartedAt))
|
||||
if (remainingMs <= 0) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hideTimer = globalThis.setTimeout(() => {
|
||||
hideTimer = null
|
||||
visible.value = false
|
||||
}, remainingMs)
|
||||
}
|
||||
|
||||
const stopWatch = watch(
|
||||
() => resolveBooleanSource(source),
|
||||
(active) => {
|
||||
if (active) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
|
||||
hide()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
clearHideTimer()
|
||||
stopWatch()
|
||||
})
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export const appViews = [
|
||||
'digitalEmployees',
|
||||
'employees',
|
||||
'policies',
|
||||
'logs',
|
||||
'settings'
|
||||
]
|
||||
|
||||
@@ -81,14 +80,6 @@ export const navItems = [
|
||||
title: '制度与知识库',
|
||||
desc: '统一管理制度文档、检索入口与知识资产。'
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
label: '系统日志',
|
||||
navHint: '查看系统运行日志',
|
||||
icon: icons.logs,
|
||||
title: '系统日志',
|
||||
desc: '集中查看系统运行日志、结构化事件和请求追踪信息。'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: '系统设置',
|
||||
@@ -107,18 +98,21 @@ const viewRouteNames = {
|
||||
policies: 'app-policies',
|
||||
audit: 'app-audit',
|
||||
digitalEmployees: 'app-digitalEmployees',
|
||||
logs: 'app-logs',
|
||||
employees: 'app-employees',
|
||||
settings: 'app-settings'
|
||||
}
|
||||
|
||||
const legacyViewRouteNames = {
|
||||
logs: 'app-settings'
|
||||
}
|
||||
|
||||
const routeNameViews = Object.fromEntries(
|
||||
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
|
||||
)
|
||||
|
||||
routeNameViews['app-request-detail'] = 'documents'
|
||||
routeNameViews['app-document-detail'] = 'documents'
|
||||
routeNameViews['app-log-detail'] = 'logs'
|
||||
routeNameViews['app-log-detail'] = 'settings'
|
||||
|
||||
export function resolveAppViewFromRoute(route) {
|
||||
const routeName = String(route?.name || '').trim()
|
||||
@@ -131,7 +125,7 @@ export function resolveAppViewFromRoute(route) {
|
||||
}
|
||||
|
||||
export function resolveTargetRouteName(view) {
|
||||
return viewRouteNames[view] || viewRouteNames.overview
|
||||
return viewRouteNames[view] || legacyViewRouteNames[view] || viewRouteNames.overview
|
||||
}
|
||||
|
||||
export function useNavigation() {
|
||||
|
||||
@@ -1024,6 +1024,7 @@ function resolveRangeMatch(activeRange, item) {
|
||||
export function useRequests() {
|
||||
const requests = ref([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const filters = reactive({ entity: '全部主体', category: '全部类型', risk: '全部状态' })
|
||||
@@ -1060,6 +1061,7 @@ export function useRequests() {
|
||||
try {
|
||||
const payload = await fetchExpenseClaims()
|
||||
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
|
||||
loaded.value = true
|
||||
} catch (nextError) {
|
||||
requests.value = []
|
||||
error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。'
|
||||
@@ -1076,11 +1078,14 @@ export function useRequests() {
|
||||
return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。`
|
||||
}
|
||||
|
||||
void reload()
|
||||
function ensureLoaded() {
|
||||
return loaded.value ? Promise.resolve() : reload()
|
||||
}
|
||||
|
||||
return {
|
||||
requests,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
search,
|
||||
filters,
|
||||
@@ -1089,6 +1094,7 @@ export function useRequests() {
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
ensureLoaded,
|
||||
reload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useThemeSkin } from './useThemeSkin.js'
|
||||
@@ -26,7 +27,20 @@ import {
|
||||
readStoredSettings
|
||||
} from '../utils/settingsModelHelper.js'
|
||||
|
||||
const sectionIds = new Set(SECTION_DEFINITIONS.map((section) => section.id))
|
||||
|
||||
function resolveSectionId(value) {
|
||||
const sectionId = String(value || '').trim()
|
||||
return sectionIds.has(sectionId) ? sectionId : 'profile'
|
||||
}
|
||||
|
||||
function resolveInitialSectionId(route) {
|
||||
return route.name === 'app-log-detail' ? 'systemLogs' : resolveSectionId(route.query.section)
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
|
||||
const {
|
||||
@@ -38,7 +52,7 @@ export function useSettings() {
|
||||
|
||||
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
|
||||
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
|
||||
const activeSection = ref('profile')
|
||||
const activeSection = ref(resolveInitialSectionId(route))
|
||||
const sessionRetentionPickerOpen = ref(false)
|
||||
const sessionRetentionPickerRef = ref(null)
|
||||
const logoInputRef = ref(null)
|
||||
@@ -55,6 +69,7 @@ export function useSettings() {
|
||||
|
||||
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
|
||||
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
|
||||
const systemLogDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const activeSectionConfig = computed(
|
||||
() => sections.find((section) => section.id === activeSection.value) || sections[0]
|
||||
)
|
||||
@@ -150,9 +165,37 @@ export function useSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function activateSection(sectionId) {
|
||||
function syncActiveSectionRoute(sectionId) {
|
||||
if (route.name !== 'app-settings') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuery = { ...route.query }
|
||||
if (sectionId === 'profile') {
|
||||
delete nextQuery.section
|
||||
} else {
|
||||
nextQuery.section = sectionId
|
||||
}
|
||||
|
||||
if (String(route.query.section || '') === String(nextQuery.section || '')) {
|
||||
return
|
||||
}
|
||||
|
||||
void router.replace({
|
||||
name: 'app-settings',
|
||||
query: nextQuery,
|
||||
hash: route.hash
|
||||
})
|
||||
}
|
||||
|
||||
function activateSection(sectionId, options = {}) {
|
||||
const nextSectionId = resolveSectionId(sectionId)
|
||||
sessionRetentionPickerOpen.value = false
|
||||
activeSection.value = sectionId
|
||||
activeSection.value = nextSectionId
|
||||
|
||||
if (!options.skipRouteSync) {
|
||||
syncActiveSectionRoute(nextSectionId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBoolean(formKey, field) {
|
||||
@@ -447,6 +490,10 @@ export function useSettings() {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSection.value === 'systemLogs') {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSection.value === 'rendering') {
|
||||
await saveRenderingSection()
|
||||
return
|
||||
@@ -462,6 +509,16 @@ export function useSettings() {
|
||||
loadSettingsSnapshot()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.name, route.query.section],
|
||||
() => {
|
||||
const nextSectionId = resolveInitialSectionId(route)
|
||||
if (activeSection.value !== nextSectionId) {
|
||||
activateSection(nextSectionId, { skipRouteSync: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
@@ -512,6 +569,7 @@ export function useSettings() {
|
||||
saveActiveSection,
|
||||
sectionStatus,
|
||||
sections,
|
||||
systemLogDetailMode,
|
||||
selectThemeSkin,
|
||||
selectSessionRetentionDays,
|
||||
themeSkinOptions,
|
||||
|
||||
@@ -259,11 +259,11 @@ export function useSetupView(props, emit) {
|
||||
})
|
||||
const testButtonIcon = computed(() => {
|
||||
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
|
||||
return 'pi pi-spin pi-spinner'
|
||||
}
|
||||
|
||||
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
|
||||
})
|
||||
return 'mdi mdi-loading mdi-spin'
|
||||
}
|
||||
|
||||
return activeSection.value === 'runtime' ? 'mdi mdi-server' : 'mdi mdi-database'
|
||||
})
|
||||
|
||||
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||
|
||||
Reference in New Issue
Block a user