diff --git a/.env.example b/.env.example index 37b9cba..59809a0 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,9 @@ WEB_PORT=5173 VITE_WEB_HOST=0.0.0.0 VITE_WEB_PORT=5173 -SERVER_HOST=127.0.0.1 +SERVER_HOST=0.0.0.0 SERVER_PORT=8000 -VITE_SERVER_HOST=127.0.0.1 +VITE_SERVER_HOST=0.0.0.0 VITE_SERVER_PORT=8000 SERVER_STARTUP_TIMEOUT=300 SERVER_BLOCKING_STARTUP_TIMEOUT=12 diff --git a/README.md b/README.md index 7018c32..59890c6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ ./start.sh all ``` +根目录 `start.sh` 是统一编排入口;前端和后端的子启动脚本分别是 `web/web_start.sh` 与 `server/server_start.sh`。 + 手动进入前端目录: ```bash diff --git a/server/start.sh b/server/server_start.sh similarity index 99% rename from server/start.sh rename to server/server_start.sh index 2fc52fe..512208d 100755 --- a/server/start.sh +++ b/server/server_start.sh @@ -33,7 +33,7 @@ set -a . "$ROOT_ENV_FILE" set +a -SERVER_HOST="${SERVER_HOST:-127.0.0.1}" +SERVER_HOST="${SERVER_HOST:-0.0.0.0}" SERVER_PORT="${SERVER_PORT:-8000}" DEFAULT_SERVER_RELOAD="false" diff --git a/server/src/app/core/config.py b/server/src/app/core/config.py index e0b6002..4aaf824 100644 --- a/server/src/app/core/config.py +++ b/server/src/app/core/config.py @@ -29,7 +29,7 @@ class Settings(BaseSettings): web_host: str = Field(default="0.0.0.0", alias="WEB_HOST") web_port: int = Field(default=5173, alias="WEB_PORT") - app_host: str = Field(default="127.0.0.1", alias="SERVER_HOST") + app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST") app_port: int = Field(default=8000, alias="SERVER_PORT") api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX") diff --git a/start.sh b/start.sh index 9b9e5d5..3ba4fe8 100755 --- a/start.sh +++ b/start.sh @@ -6,6 +6,7 @@ export MSYS_NO_PATHCONV=1 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$SCRIPT_DIR/.env" ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example" +ADMIN_SECRET_FILE="$SCRIPT_DIR/server/.secrets/admin.json" MODE="${1:-all}" RED='\033[0;31m' @@ -36,12 +37,27 @@ APP_DEBUG="${APP_DEBUG:-true}" APP_ENV="${APP_ENV:-local}" SERVER_RELOAD="${SERVER_RELOAD:-}" +setup_ready() { + [ "$SETUP_COMPLETED" = "true" ] && [ -f "$ADMIN_SECRET_FILE" ] +} + +server_probe_host() { + case "${SERVER_HOST:-127.0.0.1}" in + 0.0.0.0|::) + echo "127.0.0.1" + ;; + *) + echo "${SERVER_HOST:-127.0.0.1}" + ;; + esac +} + server_probe_url() { - echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/health" + echo "http://$(server_probe_host):${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/health" } server_smoke_url() { - echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/employees/meta" + echo "http://$(server_probe_host):${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/employees/meta" } server_probe_python() { @@ -103,7 +119,7 @@ prepare_web() { info "Preparing web dependencies..." ( cd "$SCRIPT_DIR/web" - ./start.sh deps + ./web_start.sh deps ) } @@ -111,28 +127,30 @@ prepare_server() { info "Preparing server dependencies..." ( cd "$SCRIPT_DIR/server" - ./start.sh deps + ./server_start.sh deps ) } start_web() { prepare_web cd "$SCRIPT_DIR/web" - exec ./start.sh start + exec ./web_start.sh start } start_server() { prepare_server cd "$SCRIPT_DIR/server" - exec ./start.sh start + exec ./server_start.sh start } start_setup_web() { warn "Initial setup is not completed. Starting web only." - warn "Finish the setup form first. After setup is saved, run ./start.sh again to launch FastAPI as well." + warn "Setup requires both .env completion and server/.secrets/admin.json." + warn "Finish the setup form first; the web setup bridge will start FastAPI after saving." prepare_web cd "$SCRIPT_DIR/web" - exec ./start.sh start + export X_FINANCIAL_FORCE_SETUP=true + exec ./web_start.sh start } start_all() { @@ -167,7 +185,7 @@ start_all() { info "Starting FastAPI server..." ( cd "$SCRIPT_DIR/server" - ./start.sh start + ./server_start.sh start ) & server_pid=$! started_server=true @@ -194,7 +212,7 @@ start_all() { fi wait "$server_pid" 2>/dev/null || true - error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error." + error "FastAPI process exited before becoming ready. Run ./server/server_start.sh start directly to inspect the backend error." fi sleep 1 @@ -213,7 +231,7 @@ start_all() { prepare_web info "Starting web frontend..." cd "$SCRIPT_DIR/web" - ./start.sh start + ./web_start.sh start } case "$MODE" in @@ -224,7 +242,7 @@ case "$MODE" in start_server ;; all) - if [ "$SETUP_COMPLETED" = "true" ]; then + if setup_ready; then start_all else start_setup_web diff --git a/web/src/assets/styles/views/setup-view.css b/web/src/assets/styles/views/setup-view.css index 25198a6..3980bfd 100644 --- a/web/src/assets/styles/views/setup-view.css +++ b/web/src/assets/styles/views/setup-view.css @@ -204,6 +204,15 @@ width: 100%; } +.setup-complete-progress { + display: flex; + align-items: center; + gap: 8px; + color: rgba(209, 250, 229, 0.86); + font-size: 13px; + line-height: 1.5; +} + .setup-panel { padding: 36px; display: grid; @@ -522,6 +531,185 @@ transform: none; } +.setup-modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + padding: 24px; + display: grid; + place-items: center; + background: rgba(3, 20, 15, 0.72); + backdrop-filter: blur(10px); +} + +.setup-startup-modal { + width: min(1120px, 100%); + max-height: calc(100vh - 48px); + overflow: hidden; + padding: 24px; + border: 1px solid rgba(110, 231, 183, 0.22); + border-radius: 8px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 20px; + background: + radial-gradient(circle at top right, rgba(16, 185, 129, 0.16), transparent 18rem), + linear-gradient(180deg, #05251d 0%, #081611 100%); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.42); +} + +.setup-startup-head { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; +} + +.setup-startup-head h2 { + margin-top: 4px; + color: #ffffff; + font-size: 22px; +} + +.setup-startup-head span { + display: block; + margin-top: 8px; + color: rgba(209, 250, 229, 0.78); + font-size: 13px; + line-height: 1.6; +} + +.setup-startup-spinner { + width: 54px; + height: 54px; + border: 1px solid rgba(110, 231, 183, 0.26); + border-radius: 50%; + display: grid; + place-items: center; + flex: 0 0 auto; + color: #a7f3d0; + background: rgba(6, 78, 59, 0.34); +} + +.setup-startup-spinner .pi { + font-size: 22px; +} + +.setup-startup-spinner strong { + color: #ffffff; + font-size: 20px; +} + +.setup-startup-body { + min-height: 0; + display: grid; + grid-template-columns: minmax(330px, 0.86fr) minmax(460px, 1.14fr); + gap: 18px; +} + +.setup-startup-steps { + min-height: 0; + overflow: auto; + padding-right: 2px; + display: grid; + align-content: start; + gap: 10px; +} + +.setup-startup-step { + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 8px; + display: grid; + grid-template-columns: 24px 1fr; + gap: 12px; + background: rgba(15, 23, 42, 0.24); +} + +.setup-startup-step .pi { + margin-top: 2px; + color: rgba(209, 250, 229, 0.46); +} + +.setup-startup-step strong { + color: #f8fffb; + font-size: 14px; +} + +.setup-startup-step span { + display: block; + margin-top: 4px; + color: rgba(209, 250, 229, 0.68); + font-size: 12px; + line-height: 1.5; +} + +.setup-startup-step.is-running { + border-color: rgba(59, 130, 246, 0.34); +} + +.setup-startup-step.is-running .pi { + color: #93c5fd; +} + +.setup-startup-step.is-success { + border-color: rgba(16, 185, 129, 0.32); +} + +.setup-startup-step.is-success .pi { + color: #34d399; +} + +.setup-startup-step.is-error { + border-color: rgba(248, 113, 113, 0.36); +} + +.setup-startup-step.is-error .pi { + color: #f87171; +} + +.setup-startup-console { + min-height: 0; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 8px; + overflow: hidden; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: rgba(2, 6, 23, 0.42); +} + +.setup-startup-console-head { + min-height: 44px; + padding: 0 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: rgba(15, 23, 42, 0.5); +} + +.setup-startup-console-head strong { + color: #e2e8f0; + font-size: 13px; +} + +.setup-startup-console-head span { + color: rgba(148, 163, 184, 0.9); + font-size: 12px; +} + +.setup-startup-log { + min-height: 0; + overflow: auto; + padding: 14px; + color: rgba(226, 232, 240, 0.84); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + @media (max-width: 1180px) { .setup-page { grid-template-columns: 1fr; @@ -546,6 +734,32 @@ grid-template-columns: 1fr; } + .setup-modal-backdrop { + padding: 14px; + } + + .setup-startup-modal { + max-height: calc(100vh - 28px); + padding: 18px; + } + + .setup-startup-head { + align-items: center; + } + + .setup-startup-body { + grid-template-columns: 1fr; + } + + .setup-startup-steps, + .setup-startup-console { + max-height: none; + } + + .setup-startup-log { + max-height: 260px; + } + .field-span-2 { grid-column: auto; } diff --git a/web/src/composables/useSetupView.js b/web/src/composables/useSetupView.js index 4dd3a3d..45be9ec 100644 --- a/web/src/composables/useSetupView.js +++ b/web/src/composables/useSetupView.js @@ -17,6 +17,26 @@ function readCurrentWebEndpoint(initialState) { } } +function shouldExposeServerHost() { + if (typeof window === 'undefined') { + return false + } + + const host = String(window.location.hostname || '').toLowerCase() + return Boolean(host && host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') +} + +function resolveInitialServerHost(initialState) { + const host = String(initialState?.server?.host || '0.0.0.0').trim() + const normalized = host.toLowerCase() + + if (shouldExposeServerHost() && (normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1')) { + return '0.0.0.0' + } + + return host || '0.0.0.0' +} + function createForm(initialState) { const currentWeb = readCurrentWebEndpoint(initialState) @@ -29,7 +49,7 @@ function createForm(initialState) { admin_password_confirm: '', web_host: currentWeb.host, web_port: currentWeb.port, - server_host: initialState?.server?.host || '127.0.0.1', + server_host: resolveInitialServerHost(initialState), server_port: initialState?.server?.port || 8000, postgres_host: initialState?.database?.host || '127.0.0.1', postgres_port: initialState?.database?.port || 5432, @@ -57,7 +77,9 @@ function buildPayload(form) { admin_password_confirm: String(form.admin_password_confirm || ''), web_host: currentWeb.host, web_port: currentWeb.port, - server_host: form.server_host.trim(), + server_host: shouldExposeServerHost() && ['127.0.0.1', 'localhost', '::1'].includes(form.server_host.trim().toLowerCase()) + ? '0.0.0.0' + : form.server_host.trim(), server_port: Number(form.server_port), postgres_host: form.postgres_host.trim(), postgres_port: Number(form.postgres_port), diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index 159084a..eb0169b 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -1,11 +1,16 @@ 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' @@ -22,6 +27,28 @@ const authIdleTimeoutMs = ? authIdleTimeoutMinutes * 60 * 1000 : 30 * 60 * 1000 +function resolveBrowserApiBaseUrl(state) { + const server = state?.server || {} + const configuredHost = String(server.host || '127.0.0.1').trim() + const port = Number(server.port || 8000) + const apiPrefix = String(import.meta.env.VITE_API_BASE_PREFIX || '/api/v1').replace(/\/$/, '') || '/api/v1' + + if (typeof window === 'undefined') { + return `http://${configuredHost}:${port}${apiPrefix}` + } + + const browserHost = window.location.hostname + const normalizedHost = configuredHost.toLowerCase() + const host = + normalizedHost === '0.0.0.0' || + normalizedHost === '::' || + (normalizedHost === '127.0.0.1' && browserHost && browserHost !== '127.0.0.1' && browserHost !== 'localhost') + ? browserHost + : configuredHost + + return `http://${host}:${port}${apiPrefix}` +} + let sessionRouter = null let sessionTimeoutHandle = 0 let sessionMonitoringInstalled = false @@ -42,7 +69,7 @@ function readClientBootstrapState() { port: Number(env.VITE_WEB_PORT || 5173) }, server: { - host: env.VITE_SERVER_HOST || '127.0.0.1', + host: env.VITE_SERVER_HOST || '0.0.0.0', port: Number(env.VITE_SERVER_PORT || 8000) }, database: { @@ -284,6 +311,15 @@ function syncAuthSession(options = {}) { 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() @@ -291,11 +327,29 @@ export function installSessionNavigation(router) { if (readAuthState() && !isSessionExpired()) { scheduleSessionTimeout() } + + fetchBootstrapState() + .then((state) => { + applyBootstrapState(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) @@ -351,6 +405,11 @@ function clearSetupRuntimeState() { runtimeTestMessage.value = '' databaseTestMessage.value = '' setupError.value = '' + setupProgressMessage.value = '' + setupStartupVisible.value = false + setupStartupSteps.value = [] + setupStartupLog.value = '' + setupCountdownSeconds.value = 0 } function resetFromClientEnv() { @@ -360,6 +419,45 @@ function resetFromClientEnv() { 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 = '请先完成运行端口检测。' @@ -375,14 +473,54 @@ async function handleSetupSubmit(payload) { 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('初始化配置已写入。现在可以进入登录页。') + toast('初始化完成,后端已启动。') + await runLoginCountdown() return true } catch (error) { - setupError.value = error.message || '初始化配置写入失败,请稍后重试。' + 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 { @@ -540,6 +678,11 @@ export function useSystemState() { runtimeTestPassed, runtimeTesting, setupError, + setupCountdownSeconds, + setupProgressMessage, + setupStartupLog, + setupStartupSteps, + setupStartupVisible, setupSubmitting, syncAuthSession, updateCompanyProfilePreview diff --git a/web/src/services/api.js b/web/src/services/api.js index 6850bb8..22559a0 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -1,11 +1,37 @@ -const API_BASE = String(import.meta.env.VITE_API_BASE_URL || '/api/v1').replace(/\/$/, '') +const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' + +function normalizeApiBaseUrl(value) { + return String(value || '/api/v1').replace(/\/$/, '') +} + +function readStoredApiBaseUrl() { + if (typeof window === 'undefined') { + return '' + } + + return window.localStorage.getItem(API_BASE_STORAGE_KEY) || '' +} + +let runtimeApiBaseUrl = normalizeApiBaseUrl(readStoredApiBaseUrl() || import.meta.env.VITE_API_BASE_URL || '/api/v1') + +export function setRuntimeApiBaseUrl(value) { + runtimeApiBaseUrl = normalizeApiBaseUrl(value) + + if (typeof window !== 'undefined') { + window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl) + } +} + +export function getRuntimeApiBaseUrl() { + return runtimeApiBaseUrl +} function buildUrl(path) { if (!path.startsWith('/')) { - return `${API_BASE}/${path}` + return `${runtimeApiBaseUrl}/${path}` } - return `${API_BASE}${path}` + return `${runtimeApiBaseUrl}${path}` } export async function apiRequest(path, options = {}) { diff --git a/web/src/services/bootstrap.js b/web/src/services/bootstrap.js index 95e0b1c..87dc952 100644 --- a/web/src/services/bootstrap.js +++ b/web/src/services/bootstrap.js @@ -56,6 +56,16 @@ export function saveBootstrapConfig(payload) { }) } +export function startBootstrapBackend() { + return request('/bootstrap/backend', { + method: 'POST' + }) +} + +export function fetchBootstrapBackendStatus() { + return request('/bootstrap/backend') +} + export function testBootstrapRuntime(payload) { return request('/bootstrap/runtime', { method: 'PUT', diff --git a/web/src/views/SetupRouteView.vue b/web/src/views/SetupRouteView.vue index c276691..f24fb79 100644 --- a/web/src/views/SetupRouteView.vue +++ b/web/src/views/SetupRouteView.vue @@ -9,6 +9,11 @@ :runtime-test-message="runtimeTestMessage" :database-test-message="databaseTestMessage" :error-message="setupError" + :startup-countdown-seconds="setupCountdownSeconds" + :startup-log="setupStartupLog" + :startup-steps="setupStartupSteps" + :startup-visible="setupStartupVisible" + :progress-message="setupProgressMessage" @submit="submitSetup" @runtime-test="handleRuntimeTest" @database-test="handleDatabaseTest" @@ -37,7 +42,12 @@ const { runtimeTestMessage, runtimeTestPassed, runtimeTesting, + setupCountdownSeconds, setupError, + setupProgressMessage, + setupStartupLog, + setupStartupSteps, + setupStartupVisible, setupSubmitting } = useSystemState() diff --git a/web/vite.config.js b/web/vite.config.js index 84ec5e4..cf7506e 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process' import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' import fs from 'node:fs' import net from 'node:net' @@ -15,6 +16,46 @@ const adminSecretDir = path.join(rootDir, 'server', '.secrets') const adminSecretFile = path.join(adminSecretDir, 'admin.json') const adminScryptOptions = { N: 16384, r: 8, p: 1 } const adminScryptKeyLength = 64 +let backendStartPromise = null +let backendStartState = createBackendStartState() + +function createBackendStartState() { + return { + running: false, + completed: false, + failed: false, + detail: '', + logTail: '', + steps: [ + { id: 'config', label: '第一步:读取初始化配置', status: 'pending', detail: '等待配置写入完成。' }, + { id: 'deps', label: '第二步:安装/检查后端虚拟环境', status: 'pending', detail: '等待执行 server/server_start.sh deps。' }, + { id: 'server', label: '第三步:启动 FastAPI 服务', status: 'pending', detail: '等待启动 uvicorn。' }, + { id: 'health', label: '第四步:检测后端健康状态', status: 'pending', detail: '等待 /api/v1/health 返回正常。' }, + { id: 'done', label: '第五步:配置完成', status: 'pending', detail: '后端就绪后进入登录页。' } + ] + } +} + +function cloneBackendStartState() { + return { + ...backendStartState, + steps: backendStartState.steps.map((step) => ({ ...step })) + } +} + +function updateBackendStep(id, status, detail = '') { + backendStartState.steps = backendStartState.steps.map((step) => { + if (step.id !== id) { + return step + } + + return { + ...step, + status, + detail: detail || step.detail + } + }) +} function ensureEnvFile() { if (fs.existsSync(envFile)) { @@ -177,6 +218,26 @@ function resolveClientHost(host) { return String(host || '').trim() } +function resolveBrowserApiHost(serverHost, webHost) { + const normalizedServerHost = normalizeLoopbackHost(serverHost) + const normalizedWebHost = normalizeLoopbackHost(webHost) + + if ( + (normalizedServerHost === '0.0.0.0' || normalizedServerHost === '127.0.0.1') && + normalizedWebHost && + normalizedWebHost !== '0.0.0.0' && + normalizedWebHost !== '127.0.0.1' + ) { + return String(webHost || '').trim() + } + + if (normalizedServerHost === '0.0.0.0') { + return '127.0.0.1' + } + + return String(serverHost || '').trim() +} + function hostsConflict(left, right) { const normalizedLeft = normalizeLoopbackHost(left) const normalizedRight = normalizeLoopbackHost(right) @@ -273,11 +334,27 @@ function buildCorsOrigins(payload) { function buildApiBaseUrl(payload, currentEnv) { const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1' - const host = resolveClientHost(payload.server_host) + const host = resolveBrowserApiHost(payload.server_host, payload.web_host) const port = String(payload.server_port || '').trim() return `http://${host}:${port}${apiPrefix}` } +function buildServerHealthUrl(env) { + const apiPrefix = env.API_V1_PREFIX || '/api/v1' + const host = resolveClientHost(env.SERVER_HOST || '127.0.0.1') + const port = String(env.SERVER_PORT || 8000).trim() + return `http://${host}:${port}${apiPrefix}/health` +} + +function buildBrowserReachableServerHealthUrl(env) { + const apiPrefix = env.API_V1_PREFIX || '/api/v1' + const serverHost = String(env.SERVER_HOST || '127.0.0.1').trim() + const webHost = String(env.WEB_HOST || '').trim() + const host = resolveBrowserApiHost(serverHost, webHost) + const port = String(env.SERVER_PORT || 8000).trim() + return `http://${host}:${port}${apiPrefix}/health` +} + function buildClientEnvUpdates(payload, apiBaseUrl) { return { VITE_SETUP_COMPLETED: 'true', @@ -298,22 +375,24 @@ function buildClientEnvUpdates(payload, apiBaseUrl) { } function normalizeState(env) { + const adminConfigured = Boolean(readAdminSecret()) + return { - initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true', + initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured, company: { name: env.COMPANY_NAME || '', code: env.COMPANY_CODE || '', admin_email: env.ADMIN_EMAIL || '' }, admin: { - configured: Boolean(readAdminSecret()) + configured: adminConfigured }, web: { host: env.WEB_HOST || '0.0.0.0', port: Number(env.WEB_PORT || 5173) }, server: { - host: env.SERVER_HOST || '127.0.0.1', + host: env.SERVER_HOST || '0.0.0.0', port: Number(env.SERVER_PORT || 8000) }, database: { @@ -375,10 +454,22 @@ function validateRuntimePayload(payload) { } function resolveRuntimePayload(payload, currentEnv) { + const webHost = String(payload.web_host || currentEnv.WEB_HOST || '0.0.0.0').trim() + const serverHost = String(payload.server_host || currentEnv.SERVER_HOST || '0.0.0.0').trim() + const normalizedWebHost = normalizeLoopbackHost(webHost) + const normalizedServerHost = normalizeLoopbackHost(serverHost) + return { ...payload, - web_host: String(payload.web_host || currentEnv.WEB_HOST || '0.0.0.0').trim(), - web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5173) + web_host: webHost, + web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5173), + server_host: + normalizedWebHost && + normalizedWebHost !== '127.0.0.1' && + normalizedWebHost !== '0.0.0.0' && + normalizedServerHost === '127.0.0.1' + ? '0.0.0.0' + : serverHost } } @@ -512,10 +603,178 @@ async function testDatabaseConnection(payload) { } } +async function probeBackendHealth(env) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + try { + const response = await fetch(buildServerHealthUrl(env), { + signal: controller.signal + }) + + if (!response.ok) { + return false + } + + const payload = await response.json().catch(() => null) + return payload?.status === 'ok' + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +async function probeBrowserReachableBackendHealth(env) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + try { + const response = await fetch(buildBrowserReachableServerHealthUrl(env), { + signal: controller.signal + }) + + if (!response.ok) { + return false + } + + const payload = await response.json().catch(() => null) + return payload?.status === 'ok' + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +async function waitForBackendReady(env) { + const timeoutSeconds = Number(env.SERVER_STARTUP_TIMEOUT || 300) + const maxAttempts = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 300 + let localOnlyAttempts = 0 + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const localReady = await probeBackendHealth(env) + const browserReady = await probeBrowserReachableBackendHealth(env) + + if (localReady && browserReady) { + return { + ok: true, + detail: 'FastAPI 后端已启动。' + } + } + + if (localReady && !browserReady) { + localOnlyAttempts += 1 + + if (localOnlyAttempts >= 5) { + throw new Error( + 'FastAPI 仅在本机地址可用,浏览器访问地址不可达。通常是旧后端仍以 127.0.0.1 启动并占用端口,请停止旧后端后重新完成初始化。' + ) + } + } else { + localOnlyAttempts = 0 + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + throw new Error(`FastAPI 未在 ${maxAttempts}s 内完成启动,请查看 server/logs/bootstrap-backend.log。`) +} + +function readBackendLogTail(logFile) { + if (!fs.existsSync(logFile)) { + return '' + } + + const content = fs.readFileSync(logFile, 'utf8') + const lines = content.trimEnd().split(/\r?\n/u) + return lines.slice(-30).join('\n') +} + +function completeBackendStartup(detail) { + backendStartState.running = false + backendStartState.completed = true + backendStartState.failed = false + backendStartState.detail = detail + updateBackendStep('config', 'success', '初始化配置已写入。') + updateBackendStep('deps', 'success', '后端依赖和虚拟环境检查完成。') + updateBackendStep('server', 'success', 'FastAPI 进程已启动。') + updateBackendStep('health', 'success', '健康检查通过。') + updateBackendStep('done', 'success', '配置成功,准备进入登录页。') +} + +function failBackendStartup(error, logFile) { + backendStartState.running = false + backendStartState.completed = false + backendStartState.failed = true + backendStartState.detail = error instanceof Error ? error.message : 'FastAPI 后端启动失败。' + backendStartState.logTail = readBackendLogTail(logFile) + updateBackendStep('done', 'error', backendStartState.detail) +} + +async function startBackendAndWait() { + const env = readEnvState() + const logDir = path.join(rootDir, 'server', 'logs') + const logFile = path.join(logDir, 'bootstrap-backend.log') + + if ((await probeBackendHealth(env)) && (await probeBrowserReachableBackendHealth(env))) { + backendStartState = createBackendStartState() + completeBackendStartup('FastAPI 后端已就绪。') + backendStartState.logTail = readBackendLogTail(logFile) + return cloneBackendStartState() + } + + if (!backendStartPromise) { + backendStartState = createBackendStartState() + backendStartState.running = true + backendStartState.detail = '正在启动 FastAPI 后端。' + updateBackendStep('config', 'success', '初始化配置已写入。') + updateBackendStep('deps', 'running', '正在创建/检查虚拟环境并安装依赖。') + updateBackendStep('server', 'pending', '等待依赖检查完成后启动。') + updateBackendStep('health', 'pending', '等待 FastAPI 响应。') + + backendStartPromise = (async () => { + fs.mkdirSync(logDir, { recursive: true }) + + const stdout = fs.openSync(logFile, 'a') + const stderr = fs.openSync(logFile, 'a') + const child = spawn('bash', [path.join(rootDir, 'start.sh'), 'server'], { + cwd: rootDir, + detached: true, + stdio: ['ignore', stdout, stderr] + }) + + child.unref() + updateBackendStep('server', 'running', '后端启动命令已提交,等待 uvicorn 监听端口。') + updateBackendStep('health', 'running', '正在轮询 /api/v1/health。') + + try { + await waitForBackendReady(env) + completeBackendStartup('FastAPI 后端已启动。') + } catch (error) { + failBackendStartup(error, logFile) + } finally { + backendStartState.logTail = readBackendLogTail(logFile) + } + + return cloneBackendStartState() + })().finally(() => { + backendStartPromise = null + }) + } + + backendStartState.logTail = readBackendLogTail(logFile) + return cloneBackendStartState() +} + function localSetupPlugin() { return { name: 'local-setup-api', configureServer(server) { + server.watcher.unwatch(envFile) + server.watcher.unwatch(envExampleFile) + server.watcher.unwatch(path.join(rootDir, 'server', 'logs')) + server.middlewares.use('/__setup/auth/login', async (req, res) => { try { if (req.method !== 'POST') { @@ -615,6 +874,36 @@ function localSetupPlugin() { } }) + server.middlewares.use('/__setup/bootstrap/backend', async (req, res) => { + try { + if (req.method === 'GET') { + const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log') + backendStartState.logTail = readBackendLogTail(logFile) + sendJson(res, 200, cloneBackendStartState()) + return + } + + if (req.method !== 'POST') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + try { + const result = await startBackendAndWait() + sendJson(res, 200, result) + } catch (error) { + sendJson(res, 500, { + ok: false, + detail: error instanceof Error ? error.message : 'FastAPI 后端启动失败。' + }) + } + } catch (error) { + sendJson(res, 500, { + detail: error instanceof Error ? error.message : '后端启动桥接服务异常。' + }) + } + }) + server.middlewares.use('/__setup/bootstrap', async (req, res) => { try { if (req.method === 'GET') { @@ -684,5 +973,14 @@ function localSetupPlugin() { export default defineConfig({ envDir: '..', + server: { + watch: { + ignored: [ + envFile, + envExampleFile, + path.join(rootDir, 'server', 'logs', '**') + ] + } + }, plugins: [vue(), localSetupPlugin()] }) diff --git a/web/start.sh b/web/web_start.sh similarity index 97% rename from web/start.sh rename to web/web_start.sh index a080bb1..5218c02 100755 --- a/web/start.sh +++ b/web/web_start.sh @@ -24,6 +24,11 @@ if [ -f "$ROOT_ENV_FILE" ]; then set +a fi +if [ "${X_FINANCIAL_FORCE_SETUP:-false}" = "true" ]; then + SETUP_COMPLETED=false + VITE_SETUP_COMPLETED=false +fi + WEB_HOST="${WEB_HOST:-0.0.0.0}" WEB_PORT="${WEB_PORT:-5173}"