-
-
-
-
-
-
{{ title }}
-
{{ message }}
+
+
+
+
-
+
diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js
index df56af7..f9c0ecc 100644
--- a/web/src/composables/useAppShell.js
+++ b/web/src/composables/useAppShell.js
@@ -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,
diff --git a/web/src/composables/useDocumentCenterInbox.js b/web/src/composables/useDocumentCenterInbox.js
index a914b34..faeb04f 100644
--- a/web/src/composables/useDocumentCenterInbox.js
+++ b/web/src/composables/useDocumentCenterInbox.js
@@ -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') {
diff --git a/web/src/composables/useMinimumVisibleState.js b/web/src/composables/useMinimumVisibleState.js
new file mode 100644
index 0000000..34a8c91
--- /dev/null
+++ b/web/src/composables/useMinimumVisibleState.js
@@ -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
+}
diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js
index 9834864..702229c 100644
--- a/web/src/composables/useNavigation.js
+++ b/web/src/composables/useNavigation.js
@@ -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() {
diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js
index 73ba560..0a7488e 100644
--- a/web/src/composables/useRequests.js
+++ b/web/src/composables/useRequests.js
@@ -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
}
}
diff --git a/web/src/composables/useSettings.js b/web/src/composables/useSettings.js
index ade1050..6208efc 100644
--- a/web/src/composables/useSettings.js
+++ b/web/src/composables/useSettings.js
@@ -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,
diff --git a/web/src/composables/useSetupView.js b/web/src/composables/useSetupView.js
index 0001fe1..a6283fe 100644
--- a/web/src/composables/useSetupView.js
+++ b/web/src/composables/useSetupView.js
@@ -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))
diff --git a/web/src/main.js b/web/src/main.js
index 760e167..0395f6d 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -3,7 +3,6 @@ import { MotionPlugin } from '@vueuse/motion'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
-import 'primeicons/primeicons.css'
import App from './App.vue'
import router from './router/index.js'
import { installThemeSkin } from './composables/useThemeSkin.js'
diff --git a/web/src/router/index.js b/web/src/router/index.js
index 715437e..983b205 100644
--- a/web/src/router/index.js
+++ b/web/src/router/index.js
@@ -4,10 +4,11 @@ import { checkBackendHealth } from '../composables/useBackendHealth.js'
import { appViews } from '../composables/useNavigation.js'
import { useSystemState } from '../composables/useSystemState.js'
import { canAccessAppView } from '../utils/accessControl.js'
-import AppShellRouteView from '../views/AppShellRouteView.vue'
-import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
-import LoginRouteView from '../views/LoginRouteView.vue'
-import SetupRouteView from '../views/SetupRouteView.vue'
+
+const AppShellRouteView = () => import('../views/AppShellRouteView.vue')
+const BackendUnavailableRouteView = () => import('../views/BackendUnavailableRouteView.vue')
+const LoginRouteView = () => import('../views/LoginRouteView.vue')
+const SetupRouteView = () => import('../views/SetupRouteView.vue')
const appChildRoutes = appViews
.filter((view) => view !== 'documents')
@@ -92,11 +93,24 @@ const router = createRouter({
},
{
path: '/app/logs/:logKind/:logId',
+ redirect: (to) => ({
+ name: 'app-log-detail',
+ params: { logKind: to.params.logKind, logId: to.params.logId },
+ query: to.query,
+ hash: to.hash
+ })
+ },
+ {
+ path: '/app/logs',
+ redirect: { name: 'app-settings', query: { section: 'systemLogs' } }
+ },
+ {
+ path: '/app/settings/logs/:logKind/:logId',
name: 'app-log-detail',
component: AppShellRouteView,
meta: {
requiresAuth: true,
- appView: 'logs'
+ appView: 'settings'
}
},
...appChildRoutes.map((route) => ({
diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js
index dcbf157..359a33b 100644
--- a/web/src/utils/accessControl.js
+++ b/web/src/utils/accessControl.js
@@ -3,24 +3,22 @@ export const DEFAULT_APP_VIEW_ORDER = [
'documents',
'budget',
'audit',
- 'overview',
- 'policies',
- 'digitalEmployees',
- 'logs',
- 'employees',
- 'settings'
-]
+ 'overview',
+ 'policies',
+ 'digitalEmployees',
+ 'employees',
+ 'settings'
+]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
- budget: ['budget_monitor', 'executive'],
- audit: ['finance'],
- digitalEmployees: ['finance'],
- logs: ['manager'],
- employees: ['manager'],
- settings: ['manager']
-}
+ budget: ['budget_monitor', 'executive'],
+ audit: ['finance'],
+ digitalEmployees: ['finance'],
+ employees: ['manager'],
+ settings: ['manager']
+}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
diff --git a/web/src/utils/refreshIntervalOptions.js b/web/src/utils/refreshIntervalOptions.js
new file mode 100644
index 0000000..1427684
--- /dev/null
+++ b/web/src/utils/refreshIntervalOptions.js
@@ -0,0 +1,16 @@
+export const REFRESH_INTERVAL_OPTIONS = [
+ { label: '1s', value: 1000 },
+ { label: '3s', value: 3000 },
+ { label: '5s', value: 5000 },
+ { label: '10s', value: 10000 },
+ { label: '30s', value: 30000 },
+ { label: '60s', value: 60000 },
+ { label: '180s', value: 180000 }
+]
+
+export const DEFAULT_REFRESH_INTERVAL_MS = 60000
+
+export function formatRefreshInterval(value) {
+ const option = REFRESH_INTERVAL_OPTIONS.find((item) => item.value === Number(value))
+ return option?.label || '60s'
+}
diff --git a/web/src/utils/settingsModelHelper.js b/web/src/utils/settingsModelHelper.js
index 3033b48..42681fe 100644
--- a/web/src/utils/settingsModelHelper.js
+++ b/web/src/utils/settingsModelHelper.js
@@ -70,11 +70,19 @@ export const SECTION_DEFINITIONS = [
{
id: 'logs',
label: '日志策略',
- title: '日志与审计策略',
- desc: '日志级别、留存与脱敏',
- longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。',
+ title: '日志策略',
+ desc: '日志级别、留存与路径',
+ longDesc: '定义系统日志级别、留存周期和写入路径,保证问题排查过程可追溯。',
actionLabel: '保存日志策略'
},
+ {
+ id: 'systemLogs',
+ label: '系统日志',
+ title: '系统日志',
+ desc: '运行事件、请求追踪与异常排查',
+ longDesc: '查看系统运行日志、结构化事件和请求追踪信息,作为系统设置下的排障与审计子项。',
+ actionLabel: ''
+ },
{
id: 'mail',
label: '邮箱设置',
@@ -465,6 +473,7 @@ export function computeSectionStatus(state) {
Number(state.logForm.retentionDays) > 0 &&
normalizeValue(state.logForm.logPath)
),
+ systemLogs: true,
mail: Boolean(
normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 &&
diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue
index 95bb291..d78a9d5 100644
--- a/web/src/views/AppShellRouteView.vue
+++ b/web/src/views/AppShellRouteView.vue
@@ -10,16 +10,13 @@
-
-
-
-
-
- 登录成功
- 正在进入工作台
-
-
-
+