2026-05-06 22:23:42 +08:00
|
|
|
import { computed, ref } from 'vue'
|
|
|
|
|
|
|
|
|
|
import {
|
2026-05-08 10:52:54 +08:00
|
|
|
fetchBootstrapBackendStatus,
|
|
|
|
|
fetchBootstrapState,
|
2026-05-06 22:23:42 +08:00
|
|
|
saveBootstrapConfig,
|
2026-05-08 10:52:54 +08:00
|
|
|
startBootstrapBackend,
|
2026-05-06 22:23:42 +08:00
|
|
|
testBootstrapDatabase,
|
|
|
|
|
testBootstrapRuntime
|
|
|
|
|
} from '../services/bootstrap.js'
|
2026-05-07 14:34:42 +08:00
|
|
|
import { login as loginByAccount } from '../services/auth.js'
|
2026-05-08 10:52:54 +08:00
|
|
|
import { setRuntimeApiBaseUrl } from '../services/api.js'
|
|
|
|
|
import { checkBackendHealth } from './useBackendHealth.js'
|
2026-05-07 14:34:42 +08:00
|
|
|
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
|
2026-05-06 22:23:42 +08:00
|
|
|
import { useToast } from './useToast.js'
|
|
|
|
|
|
|
|
|
|
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
|
2026-05-07 11:50:10 +08:00
|
|
|
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
|
2026-05-07 14:34:42 +08:00
|
|
|
const AUTH_USER_KEY = 'x-financial-auth-user'
|
2026-05-07 11:50:10 +08:00
|
|
|
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
|
|
|
|
const DEFAULT_USER_NAME = '系统管理员'
|
2026-05-07 14:34:42 +08:00
|
|
|
const DEFAULT_USER_ROLE = '管理员'
|
2026-05-07 11:50:10 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-09 09:29:34 +08:00
|
|
|
function resolveBrowserApiBaseUrl() {
|
|
|
|
|
return '/api/v1'
|
2026-05-08 10:52:54 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 11:50:10 +08:00
|
|
|
let sessionRouter = null
|
|
|
|
|
let sessionTimeoutHandle = 0
|
|
|
|
|
let sessionMonitoringInstalled = false
|
|
|
|
|
let lastActivityWriteAt = 0
|
2026-05-06 22:23:42 +08:00
|
|
|
|
|
|
|
|
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: {
|
2026-05-08 09:27:45 +08:00
|
|
|
host: env.VITE_WEB_HOST || '0.0.0.0',
|
2026-05-06 22:23:42 +08:00
|
|
|
port: Number(env.VITE_WEB_PORT || 5173)
|
|
|
|
|
},
|
|
|
|
|
server: {
|
2026-05-08 10:52:54 +08:00
|
|
|
host: env.VITE_SERVER_HOST || '0.0.0.0',
|
2026-05-06 22:23:42 +08:00
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 11:50:10 +08:00
|
|
|
function readStoredUsername() {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 14:34:42 +08:00
|
|
|
function buildAnonymousUser() {
|
|
|
|
|
return {
|
|
|
|
|
username: '',
|
|
|
|
|
name: '',
|
|
|
|
|
role: '',
|
|
|
|
|
roleCodes: [],
|
|
|
|
|
email: '',
|
|
|
|
|
avatar: '',
|
|
|
|
|
isAdmin: false
|
2026-05-07 11:50:10 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 14:34:42 +08:00
|
|
|
function buildLegacyAdminUser(username = '') {
|
2026-05-07 11:50:10 +08:00
|
|
|
const normalized = String(username || '').trim()
|
|
|
|
|
const name = normalized || DEFAULT_USER_NAME
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-07 14:34:42 +08:00
|
|
|
username: normalized,
|
2026-05-07 11:50:10 +08:00
|
|
|
name,
|
|
|
|
|
role: DEFAULT_USER_ROLE,
|
2026-05-07 14:34:42 +08:00
|
|
|
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
|
2026-05-07 11:50:10 +08:00
|
|
|
}
|
2026-05-07 14:34:42 +08:00
|
|
|
|
|
|
|
|
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
|
2026-05-07 11:50:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSessionExpired(now = Date.now()) {
|
|
|
|
|
if (!readAuthState()) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lastActivityAt = readLastActivityAt()
|
|
|
|
|
|
|
|
|
|
if (!lastActivityAt) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return now - lastActivityAt > authIdleTimeoutMs
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 14:34:42 +08:00
|
|
|
function persistAuthState(value, user = null) {
|
2026-05-06 22:23:42 +08:00
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value) {
|
|
|
|
|
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
|
2026-05-07 14:34:42 +08:00
|
|
|
const normalizedUser = user || buildAnonymousUser()
|
|
|
|
|
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
|
|
|
|
|
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
2026-05-06 22:23:42 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
2026-05-07 11:50:10 +08:00
|
|
|
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
2026-05-07 14:34:42 +08:00
|
|
|
window.sessionStorage.removeItem(AUTH_USER_KEY)
|
2026-05-07 11:50:10 +08:00
|
|
|
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
|
2026-05-07 14:34:42 +08:00
|
|
|
currentUser.value = buildAnonymousUser()
|
2026-05-07 11:50:10 +08:00
|
|
|
clearSessionTimeout()
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSessionExpired()) {
|
|
|
|
|
logout('timeout', { notify: shouldNotify, redirect: false })
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loggedIn.value = true
|
2026-05-07 14:34:42 +08:00
|
|
|
currentUser.value = readStoredUser()
|
2026-05-07 11:50:10 +08:00
|
|
|
scheduleSessionTimeout()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:52:54 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 11:50:10 +08:00
|
|
|
export function installSessionNavigation(router) {
|
|
|
|
|
sessionRouter = router
|
|
|
|
|
installSessionMonitoring()
|
|
|
|
|
|
|
|
|
|
if (readAuthState() && !isSessionExpired()) {
|
|
|
|
|
scheduleSessionTimeout()
|
|
|
|
|
}
|
2026-05-08 10:52:54 +08:00
|
|
|
|
|
|
|
|
fetchBootstrapState()
|
|
|
|
|
.then((state) => {
|
|
|
|
|
applyBootstrapState(state)
|
2026-05-08 11:14:04 +08:00
|
|
|
setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state))
|
2026-05-08 10:52:54 +08:00
|
|
|
router.isReady().then(() => reconcileEntryRoute(router))
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
router.isReady().then(() => {
|
|
|
|
|
if (!isInitialized.value && router.currentRoute.value.name !== 'setup') {
|
|
|
|
|
router.replace({ name: 'setup' })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bootstrapState = ref(readClientBootstrapState())
|
|
|
|
|
const setupSubmitting = ref(false)
|
|
|
|
|
const setupError = ref('')
|
2026-05-08 10:52:54 +08:00
|
|
|
const setupProgressMessage = ref('')
|
|
|
|
|
const setupStartupVisible = ref(false)
|
|
|
|
|
const setupStartupSteps = ref([])
|
|
|
|
|
const setupStartupLog = ref('')
|
|
|
|
|
const setupCountdownSeconds = ref(0)
|
2026-05-06 22:23:42 +08:00
|
|
|
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('')
|
2026-05-07 11:50:10 +08:00
|
|
|
const loggedIn = ref(readAuthState() && !isSessionExpired())
|
2026-05-07 14:34:42 +08:00
|
|
|
const currentUser = ref(readStoredUser())
|
2026-05-07 11:50:10 +08:00
|
|
|
|
|
|
|
|
if (!loggedIn.value && readAuthState()) {
|
|
|
|
|
persistAuthState(false)
|
|
|
|
|
}
|
2026-05-06 22:23:42 +08:00
|
|
|
|
|
|
|
|
const { toast } = useToast()
|
|
|
|
|
|
|
|
|
|
const companyProfile = computed(() => ({
|
|
|
|
|
name: bootstrapState.value.company?.name || '',
|
|
|
|
|
code: bootstrapState.value.company?.code || '',
|
|
|
|
|
adminEmail: bootstrapState.value.company?.admin_email || ''
|
|
|
|
|
}))
|
|
|
|
|
|
2026-05-07 15:55:23 +08:00
|
|
|
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 } : {})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 22:23:42 +08:00
|
|
|
const isInitialized = computed(() => Boolean(bootstrapState.value.initialized))
|
|
|
|
|
|
|
|
|
|
function applyBootstrapState(state) {
|
|
|
|
|
bootstrapState.value = state
|
|
|
|
|
|
|
|
|
|
if (!state.initialized) {
|
2026-05-07 11:50:10 +08:00
|
|
|
logout('reset', { redirect: false })
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearSetupRuntimeState() {
|
|
|
|
|
runtimeTesting.value = false
|
|
|
|
|
databaseTesting.value = false
|
|
|
|
|
runtimeTestPassed.value = false
|
|
|
|
|
databaseTestPassed.value = false
|
|
|
|
|
runtimeTestMessage.value = ''
|
|
|
|
|
databaseTestMessage.value = ''
|
|
|
|
|
setupError.value = ''
|
2026-05-08 10:52:54 +08:00
|
|
|
setupProgressMessage.value = ''
|
|
|
|
|
setupStartupVisible.value = false
|
|
|
|
|
setupStartupSteps.value = []
|
|
|
|
|
setupStartupLog.value = ''
|
|
|
|
|
setupCountdownSeconds.value = 0
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetFromClientEnv() {
|
|
|
|
|
applyBootstrapState(readClientBootstrapState())
|
|
|
|
|
clearSetupRuntimeState()
|
|
|
|
|
loginError.value = ''
|
2026-05-07 14:34:42 +08:00
|
|
|
currentUser.value = readStoredUser()
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:52:54 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 22:23:42 +08:00
|
|
|
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 = ''
|
2026-05-08 10:52:54 +08:00
|
|
|
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
|
2026-05-06 22:23:42 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const state = await saveBootstrapConfig(payload)
|
2026-05-08 10:52:54 +08:00
|
|
|
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 = '配置成功,准备进入登录页...'
|
2026-05-06 22:23:42 +08:00
|
|
|
applyBootstrapState(state)
|
2026-05-08 10:52:54 +08:00
|
|
|
toast('初始化完成,后端已启动。')
|
|
|
|
|
await runLoginCountdown()
|
2026-05-06 22:23:42 +08:00
|
|
|
return true
|
|
|
|
|
} catch (error) {
|
2026-05-08 10:52:54 +08:00
|
|
|
setupError.value = error.message || '初始化配置写入或后端启动失败,请稍后重试。'
|
|
|
|
|
setupStartupVisible.value = true
|
|
|
|
|
setupStartupSteps.value = setupStartupSteps.value.map((step) =>
|
|
|
|
|
step.id === 'done'
|
|
|
|
|
? { ...step, status: 'error', detail: setupError.value }
|
|
|
|
|
: step
|
|
|
|
|
)
|
2026-05-06 22:23:42 +08:00
|
|
|
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 {
|
2026-05-07 14:34:42 +08:00
|
|
|
const response = await loginByAccount({
|
2026-05-06 22:23:42 +08:00
|
|
|
username: credentials.username,
|
|
|
|
|
password: credentials.password
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-07 14:34:42 +08:00
|
|
|
const user = response?.user || buildAnonymousUser()
|
2026-05-06 22:23:42 +08:00
|
|
|
loggedIn.value = true
|
2026-05-07 14:34:42 +08:00
|
|
|
persistAuthState(true, user)
|
|
|
|
|
currentUser.value = user
|
2026-05-07 11:50:10 +08:00
|
|
|
touchAuthActivity(true)
|
2026-05-06 22:23:42 +08:00
|
|
|
return true
|
|
|
|
|
} catch (error) {
|
2026-05-07 11:50:10 +08:00
|
|
|
logout('invalid', { redirect: false })
|
2026-05-07 14:34:42 +08:00
|
|
|
loginError.value = error.message || '登录失败,请检查账号和密码。'
|
2026-05-06 22:23:42 +08:00
|
|
|
toast(loginError.value)
|
|
|
|
|
return false
|
|
|
|
|
} finally {
|
|
|
|
|
loginSubmitting.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 11:50:10 +08:00
|
|
|
function logout(reason = 'manual', options = {}) {
|
|
|
|
|
const notify = options.notify ?? reason === 'timeout'
|
|
|
|
|
const redirect = options.redirect ?? reason !== 'invalid'
|
|
|
|
|
|
2026-05-06 22:23:42 +08:00
|
|
|
loggedIn.value = false
|
|
|
|
|
persistAuthState(false)
|
2026-05-07 14:34:42 +08:00
|
|
|
currentUser.value = buildAnonymousUser()
|
2026-05-07 11:50:10 +08:00
|
|
|
clearSessionTimeout()
|
|
|
|
|
|
|
|
|
|
if (notify) {
|
|
|
|
|
toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (redirect) {
|
|
|
|
|
redirectToLogin()
|
|
|
|
|
}
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleRecoverPassword() {
|
2026-05-07 14:34:42 +08:00
|
|
|
toast('请联系系统管理员重置账号密码。')
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSsoLogin() {
|
|
|
|
|
toast('SSO 登录暂未启用。')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveEntryRoute() {
|
2026-05-07 11:50:10 +08:00
|
|
|
loggedIn.value = syncAuthSession()
|
2026-05-07 14:34:42 +08:00
|
|
|
currentUser.value = readStoredUser()
|
2026-05-07 11:50:10 +08:00
|
|
|
|
2026-05-06 22:23:42 +08:00
|
|
|
if (!isInitialized.value) {
|
|
|
|
|
return { name: 'setup' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!loggedIn.value) {
|
|
|
|
|
return { name: 'login' }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 14:34:42 +08:00
|
|
|
return resolveDefaultAuthorizedRoute(currentUser.value)
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useSystemState() {
|
|
|
|
|
return {
|
|
|
|
|
bootstrapState,
|
|
|
|
|
companyProfile,
|
2026-05-07 11:50:10 +08:00
|
|
|
currentUser,
|
2026-05-06 22:23:42 +08:00
|
|
|
databaseTestMessage,
|
|
|
|
|
databaseTestPassed,
|
|
|
|
|
databaseTesting,
|
|
|
|
|
handleDatabaseDirty,
|
|
|
|
|
handleDatabaseTest,
|
|
|
|
|
handleLogin,
|
|
|
|
|
handleRecoverPassword,
|
|
|
|
|
handleRuntimeDirty,
|
|
|
|
|
handleRuntimeTest,
|
|
|
|
|
handleSetupSubmit,
|
|
|
|
|
handleSsoLogin,
|
|
|
|
|
isInitialized,
|
|
|
|
|
loggedIn,
|
|
|
|
|
loginError,
|
|
|
|
|
loginSubmitting,
|
|
|
|
|
logout,
|
|
|
|
|
resetFromClientEnv,
|
|
|
|
|
resolveEntryRoute,
|
|
|
|
|
runtimeTestMessage,
|
|
|
|
|
runtimeTestPassed,
|
|
|
|
|
runtimeTesting,
|
|
|
|
|
setupError,
|
2026-05-08 10:52:54 +08:00
|
|
|
setupCountdownSeconds,
|
|
|
|
|
setupProgressMessage,
|
|
|
|
|
setupStartupLog,
|
|
|
|
|
setupStartupSteps,
|
|
|
|
|
setupStartupVisible,
|
2026-05-07 11:50:10 +08:00
|
|
|
setupSubmitting,
|
2026-05-07 15:55:23 +08:00
|
|
|
syncAuthSession,
|
|
|
|
|
updateCompanyProfilePreview
|
2026-05-06 22:23:42 +08:00
|
|
|
}
|
|
|
|
|
}
|