feat(ui): finalize shared shells and loading states

This commit is contained in:
caoxiaozhu
2026-05-29 13:17:39 +08:00
parent 64cc76c970
commit e080105f9f
52 changed files with 1559 additions and 861 deletions

View File

@@ -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,

View File

@@ -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') {

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

View File

@@ -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() {

View File

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

View File

@@ -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,

View File

@@ -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))