import { computed, ref } from 'vue' import { fetchBootstrapBackendStatus, fetchBootstrapState, saveBootstrapConfig, startBootstrapBackend, testBootstrapDatabase, testBootstrapRuntime } from '../services/bootstrap.js' import { login as loginByAccount } from '../services/auth.js' import { setRuntimeApiBaseUrl } from '../services/api.js' import { checkBackendHealth } from './useBackendHealth.js' import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' import { useToast } from './useToast.js' const AUTH_STORAGE_KEY = 'x-financial-authenticated' const AUTH_USERNAME_KEY = 'x-financial-auth-username' const AUTH_USER_KEY = 'x-financial-auth-user' const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity' const DEFAULT_USER_NAME = '系统管理员' const DEFAULT_USER_ROLE = '管理员' const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange'] const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30) const authIdleTimeoutMs = Number.isFinite(authIdleTimeoutMinutes) && authIdleTimeoutMinutes > 0 ? authIdleTimeoutMinutes * 60 * 1000 : 30 * 60 * 1000 function resolveBrowserApiBaseUrl() { return '/api/v1' } let sessionRouter = null let sessionTimeoutHandle = 0 let sessionMonitoringInstalled = false let lastActivityWriteAt = 0 function readClientBootstrapState() { const env = import.meta.env return { initialized: String(env.VITE_SETUP_COMPLETED || '').toLowerCase() === 'true', company: { name: env.VITE_COMPANY_NAME || '', code: env.VITE_COMPANY_CODE || '', admin_email: env.VITE_ADMIN_EMAIL || '' }, web: { host: env.VITE_WEB_HOST || '0.0.0.0', port: Number(env.VITE_WEB_PORT || 5173) }, server: { host: env.VITE_SERVER_HOST || '0.0.0.0', port: Number(env.VITE_SERVER_PORT || 8000) }, database: { driver: 'postgresql', host: env.VITE_POSTGRES_HOST || '127.0.0.1', port: Number(env.VITE_POSTGRES_PORT || 5432), name: env.VITE_POSTGRES_DB || 'x_financial', username: env.VITE_POSTGRES_USER || 'postgres', password_configured: false }, redis: { enabled: Boolean(env.VITE_REDIS_URL), url: env.VITE_REDIS_URL || '' } } } function readAuthState() { if (typeof window === 'undefined') { return false } return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true' } function readStoredUsername() { if (typeof window === 'undefined') { return '' } return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || '' } function buildAnonymousUser() { return { username: '', name: '', role: '', roleCodes: [], email: '', avatar: '', isAdmin: false } } function buildLegacyAdminUser(username = '') { const normalized = String(username || '').trim() const name = normalized || DEFAULT_USER_NAME return { username: normalized, name, role: DEFAULT_USER_ROLE, roleCodes: ['manager'], email: '', avatar: name.slice(0, 1).toUpperCase(), isAdmin: true } } function readStoredUser() { if (typeof window === 'undefined') { return buildAnonymousUser() } const raw = window.sessionStorage.getItem(AUTH_USER_KEY) if (raw) { try { const payload = JSON.parse(raw) if (payload && typeof payload === 'object') { const username = String(payload.username || '').trim() const name = String(payload.name || username || DEFAULT_USER_NAME).trim() const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : [] return { username, name, role: String(payload.role || DEFAULT_USER_ROLE), roleCodes, email: String(payload.email || ''), avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), isAdmin: Boolean(payload.isAdmin) } } } catch { return buildLegacyAdminUser(readStoredUsername()) } } const legacyUsername = readStoredUsername() return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser() } function readLastActivityAt() { if (typeof window === 'undefined') { return 0 } return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0) } function isSessionExpired(now = Date.now()) { if (!readAuthState()) { return false } const lastActivityAt = readLastActivityAt() if (!lastActivityAt) { return true } return now - lastActivityAt > authIdleTimeoutMs } function persistAuthState(value, user = null) { if (typeof window === 'undefined') { return } if (value) { window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true') const normalizedUser = user || buildAnonymousUser() window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim()) window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) return } window.sessionStorage.removeItem(AUTH_STORAGE_KEY) window.sessionStorage.removeItem(AUTH_USERNAME_KEY) window.sessionStorage.removeItem(AUTH_USER_KEY) window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY) } function clearSessionTimeout() { if (typeof window === 'undefined' || !sessionTimeoutHandle) { return } window.clearTimeout(sessionTimeoutHandle) sessionTimeoutHandle = 0 } function redirectToLogin() { if (sessionRouter?.currentRoute?.value?.name === 'login') { return } if (sessionRouter) { sessionRouter.replace({ name: 'login' }) return } if (typeof window !== 'undefined' && window.location.pathname !== '/login') { window.location.assign('/login') } } function scheduleSessionTimeout() { clearSessionTimeout() if (typeof window === 'undefined' || !readAuthState()) { return } const lastActivityAt = readLastActivityAt() if (!lastActivityAt) { return } const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt) if (remaining <= 0) { logout('timeout', { notify: true }) return } sessionTimeoutHandle = window.setTimeout(() => { logout('timeout', { notify: true }) }, remaining) } function touchAuthActivity(force = false) { if (typeof window === 'undefined' || !readAuthState()) { return } const now = Date.now() if (!force && now - lastActivityWriteAt < 1000) { scheduleSessionTimeout() return } window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now)) lastActivityWriteAt = now scheduleSessionTimeout() } function handleSessionActivity(event) { if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') { return } touchAuthActivity() } function installSessionMonitoring() { if (sessionMonitoringInstalled || typeof window === 'undefined') { return } sessionMonitoringInstalled = true SESSION_ACTIVITY_EVENTS.forEach((eventName) => { window.addEventListener(eventName, handleSessionActivity, { passive: true }) }) } function syncAuthSession(options = {}) { const shouldNotify = Boolean(options.notify) if (!readAuthState()) { loggedIn.value = false currentUser.value = buildAnonymousUser() clearSessionTimeout() return false } if (isSessionExpired()) { logout('timeout', { notify: shouldNotify, redirect: false }) return false } loggedIn.value = true currentUser.value = readStoredUser() scheduleSessionTimeout() return true } function reconcileEntryRoute(router) { const target = resolveEntryRoute() const current = router.currentRoute.value if (!current.name || current.name === 'root' || current.name === 'setup' || target.name === 'setup') { router.replace(target) } } export function installSessionNavigation(router) { sessionRouter = router installSessionMonitoring() if (readAuthState() && !isSessionExpired()) { scheduleSessionTimeout() } fetchBootstrapState() .then((state) => { applyBootstrapState(state) setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) router.isReady().then(() => reconcileEntryRoute(router)) }) .catch(() => { router.isReady().then(() => { if (!isInitialized.value && router.currentRoute.value.name !== 'setup') { router.replace({ name: 'setup' }) } }) }) } const bootstrapState = ref(readClientBootstrapState()) const setupSubmitting = ref(false) const setupError = ref('') const setupProgressMessage = ref('') const setupStartupVisible = ref(false) const setupStartupSteps = ref([]) const setupStartupLog = ref('') const setupCountdownSeconds = ref(0) const runtimeTesting = ref(false) const databaseTesting = ref(false) const runtimeTestPassed = ref(false) const databaseTestPassed = ref(false) const runtimeTestMessage = ref('') const databaseTestMessage = ref('') const loginSubmitting = ref(false) const loginError = ref('') const loggedIn = ref(readAuthState() && !isSessionExpired()) const currentUser = ref(readStoredUser()) if (!loggedIn.value && readAuthState()) { persistAuthState(false) } const { toast } = useToast() const companyProfile = computed(() => ({ name: bootstrapState.value.company?.name || '', code: bootstrapState.value.company?.code || '', adminEmail: bootstrapState.value.company?.admin_email || '' })) function updateCompanyProfilePreview(payload = {}) { const currentCompany = bootstrapState.value.company || {} bootstrapState.value = { ...bootstrapState.value, company: { ...currentCompany, ...(payload.name !== undefined ? { name: payload.name } : {}), ...(payload.code !== undefined ? { code: payload.code } : {}), ...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {}) } } } const isInitialized = computed(() => Boolean(bootstrapState.value.initialized)) function applyBootstrapState(state) { bootstrapState.value = state if (!state.initialized) { logout('reset', { redirect: false }) } } function clearSetupRuntimeState() { runtimeTesting.value = false databaseTesting.value = false runtimeTestPassed.value = false databaseTestPassed.value = false runtimeTestMessage.value = '' databaseTestMessage.value = '' setupError.value = '' setupProgressMessage.value = '' setupStartupVisible.value = false setupStartupSteps.value = [] setupStartupLog.value = '' setupCountdownSeconds.value = 0 } function resetFromClientEnv() { applyBootstrapState(readClientBootstrapState()) clearSetupRuntimeState() loginError.value = '' currentUser.value = readStoredUser() } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } function applyBackendStartupStatus(status) { setupStartupVisible.value = true setupStartupSteps.value = Array.isArray(status?.steps) ? status.steps : [] setupStartupLog.value = status?.logTail || '' setupProgressMessage.value = status?.detail || setupProgressMessage.value } async function waitForBackendStartup() { const started = await startBootstrapBackend() applyBackendStartupStatus(started) while (true) { const status = await fetchBootstrapBackendStatus() applyBackendStartupStatus(status) if (status?.completed) { return status } if (status?.failed) { throw new Error(status.detail || 'FastAPI 后端启动失败。') } await sleep(1000) } } async function runLoginCountdown() { for (let second = 5; second > 0; second -= 1) { setupCountdownSeconds.value = second setupProgressMessage.value = `配置成功,${second} 秒后进入登录页...` await sleep(1000) } } async function handleSetupSubmit(payload) { if (!runtimeTestPassed.value) { setupError.value = '请先完成运行端口检测。' toast(setupError.value) return false } if (!databaseTestPassed.value) { setupError.value = '请先完成数据库连接检测。' toast(setupError.value) return false } setupSubmitting.value = true setupError.value = '' setupProgressMessage.value = '正在写入初始化配置...' setupStartupVisible.value = true setupStartupSteps.value = [ { id: 'config', label: '第一步:写入初始化配置', status: 'running', detail: '正在保存企业、管理员、端口和数据库配置。' }, { id: 'deps', label: '第二步:安装/检查后端虚拟环境', status: 'pending', detail: '等待后端启动任务开始。' }, { id: 'server', label: '第三步:启动 FastAPI 服务', status: 'pending', detail: '等待启动 uvicorn。' }, { id: 'health', label: '第四步:检测后端健康状态', status: 'pending', detail: '等待 /api/v1/health。' }, { id: 'done', label: '第五步:配置完成', status: 'pending', detail: '后端就绪后进入登录页。' } ] setupStartupLog.value = '' setupCountdownSeconds.value = 0 try { const state = await saveBootstrapConfig(payload) setupStartupSteps.value = setupStartupSteps.value.map((step) => step.id === 'config' ? { ...step, status: 'success', detail: '初始化配置已写入。' } : step ) setupProgressMessage.value = '配置已写入,正在启动 FastAPI 后端...' await waitForBackendStartup() setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) setupProgressMessage.value = '后端已启动,正在检测服务连通性...' const backendReady = await checkBackendHealth({ force: true }) if (!backendReady) { throw new Error('FastAPI 后端已启动,但浏览器暂时无法连接后端接口。请确认 Server Host 使用 0.0.0.0,且防火墙允许访问后端端口。') } setupStartupSteps.value = setupStartupSteps.value.map((step) => step.id === 'done' ? { ...step, status: 'success', detail: '配置成功,准备进入登录页。' } : step ) setupProgressMessage.value = '配置成功,准备进入登录页...' applyBootstrapState(state) toast('初始化完成,后端已启动。') await runLoginCountdown() return true } catch (error) { setupError.value = error.message || '初始化配置写入或后端启动失败,请稍后重试。' setupStartupVisible.value = true setupStartupSteps.value = setupStartupSteps.value.map((step) => step.id === 'done' ? { ...step, status: 'error', detail: setupError.value } : step ) toast(setupError.value) return false } finally { setupSubmitting.value = false } } async function handleRuntimeTest(payload) { runtimeTesting.value = true runtimeTestMessage.value = '' setupError.value = '' try { const result = await testBootstrapRuntime(payload) runtimeTestPassed.value = true runtimeTestMessage.value = result.detail || '端口占用检测通过。' toast(runtimeTestMessage.value) } catch (error) { runtimeTestPassed.value = false runtimeTestMessage.value = error.message || '端口占用检测失败。' toast(runtimeTestMessage.value) } finally { runtimeTesting.value = false } } async function handleDatabaseTest(payload) { databaseTesting.value = true databaseTestMessage.value = '' setupError.value = '' try { const result = await testBootstrapDatabase(payload) databaseTestPassed.value = true databaseTestMessage.value = result.detail || '数据库连接检测通过。' toast(databaseTestMessage.value) } catch (error) { databaseTestPassed.value = false databaseTestMessage.value = error.message || '数据库连接检测失败。' toast(databaseTestMessage.value) } finally { databaseTesting.value = false } } function handleRuntimeDirty() { runtimeTestPassed.value = false runtimeTestMessage.value = '' if (setupError.value === '请先完成运行端口检测。') { setupError.value = '' } } function handleDatabaseDirty() { databaseTestPassed.value = false databaseTestMessage.value = '' if (setupError.value === '请先完成数据库连接检测。') { setupError.value = '' } } async function handleLogin(credentials) { loginSubmitting.value = true loginError.value = '' try { const response = await loginByAccount({ username: credentials.username, password: credentials.password }) const user = response?.user || buildAnonymousUser() loggedIn.value = true persistAuthState(true, user) currentUser.value = user touchAuthActivity(true) return true } catch (error) { logout('invalid', { redirect: false }) loginError.value = error.message || '登录失败,请检查账号和密码。' toast(loginError.value) return false } finally { loginSubmitting.value = false } } function logout(reason = 'manual', options = {}) { const notify = options.notify ?? reason === 'timeout' const redirect = options.redirect ?? reason !== 'invalid' loggedIn.value = false persistAuthState(false) currentUser.value = buildAnonymousUser() clearSessionTimeout() if (notify) { toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。') } if (redirect) { redirectToLogin() } } function handleRecoverPassword() { toast('请联系系统管理员重置账号密码。') } function handleSsoLogin() { toast('SSO 登录暂未启用。') } function resolveEntryRoute() { loggedIn.value = syncAuthSession() currentUser.value = readStoredUser() if (!isInitialized.value) { return { name: 'setup' } } if (!loggedIn.value) { return { name: 'login' } } return resolveDefaultAuthorizedRoute(currentUser.value) } export function useSystemState() { return { bootstrapState, companyProfile, currentUser, databaseTestMessage, databaseTestPassed, databaseTesting, handleDatabaseDirty, handleDatabaseTest, handleLogin, handleRecoverPassword, handleRuntimeDirty, handleRuntimeTest, handleSetupSubmit, handleSsoLogin, isInitialized, loggedIn, loginError, loginSubmitting, logout, resetFromClientEnv, resolveEntryRoute, runtimeTestMessage, runtimeTestPassed, runtimeTesting, setupError, setupCountdownSeconds, setupProgressMessage, setupStartupLog, setupStartupSteps, setupStartupVisible, setupSubmitting, syncAuthSession, updateCompanyProfilePreview } }