Files
X-Financial/web/vite.config.js
2026-05-29 13:17:39 +08:00

1089 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process'
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import fs from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const envFile = path.join(rootDir, '.env')
const envExampleFile = path.join(rootDir, '.env.example')
const preferPollingWatcher = fs.existsSync('/.dockerenv') || process.env.VITE_USE_POLLING === 'true'
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)) {
return
}
if (fs.existsSync(envExampleFile)) {
fs.copyFileSync(envExampleFile, envFile)
return
}
fs.writeFileSync(envFile, '', 'utf8')
}
function ensureAdminSecretDir() {
fs.mkdirSync(adminSecretDir, { recursive: true })
}
function parseEnv(text) {
const result = {}
for (const line of text.split(/\r?\n/u)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex === -1) {
continue
}
const key = trimmed.slice(0, separatorIndex).trim()
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
result[key] = value
}
return result
}
const envOverridePrefixes = ['APP_', 'WEB_', 'SERVER_', 'POSTGRES_', 'VITE_', 'LOG_']
const envOverrideKeys = new Set([
'API_V1_PREFIX',
'SETUP_COMPLETED',
'COMPANY_NAME',
'COMPANY_CODE',
'ADMIN_EMAIL',
'DATABASE_URL',
'SQLALCHEMY_ECHO',
'REDIS_URL',
'CORS_ORIGINS'
])
function shouldOverlayEnvKey(key) {
return envOverrideKeys.has(key) || envOverridePrefixes.some((prefix) => key.startsWith(prefix))
}
function readEnvState() {
ensureEnvFile()
const state = parseEnv(fs.readFileSync(envFile, 'utf8'))
for (const [key, value] of Object.entries(process.env)) {
if (!shouldOverlayEnvKey(key) || value == null || value === '') {
continue
}
state[key] = String(value)
}
return state
}
function readAdminSecret() {
if (!fs.existsSync(adminSecretFile)) {
return null
}
try {
const payload = JSON.parse(fs.readFileSync(adminSecretFile, 'utf8'))
if (
payload &&
payload.algorithm === 'scrypt' &&
typeof payload.username === 'string' &&
typeof payload.salt === 'string' &&
typeof payload.derived_key === 'string'
) {
return payload
}
} catch {
return null
}
return null
}
function hashAdminPassword(password, salt, keyLength = adminScryptKeyLength, options = adminScryptOptions) {
return scryptSync(password, Buffer.from(salt, 'hex'), keyLength, options)
}
function persistAdminCredentials(payload) {
ensureAdminSecretDir()
const existing = readAdminSecret()
const salt = randomBytes(16).toString('hex')
const now = new Date().toISOString()
const derivedKey = hashAdminPassword(String(payload.admin_password || ''), salt)
const record = {
version: 1,
algorithm: 'scrypt',
username: String(payload.admin_username || '').trim(),
salt,
derived_key: derivedKey.toString('hex'),
key_length: adminScryptKeyLength,
...adminScryptOptions,
created_at: existing?.created_at || now,
updated_at: now
}
fs.writeFileSync(adminSecretFile, `${JSON.stringify(record, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
}
function verifyAdminCredentials(username, password) {
const record = readAdminSecret()
if (!record) {
throw new Error('管理员账号尚未初始化,请先完成初始化配置。')
}
if (record.username !== String(username || '').trim()) {
return false
}
const derivedKey = hashAdminPassword(
String(password || ''),
record.salt,
Number(record.key_length || adminScryptKeyLength),
{
N: Number(record.N || adminScryptOptions.N),
r: Number(record.r || adminScryptOptions.r),
p: Number(record.p || adminScryptOptions.p)
}
)
const storedKey = Buffer.from(record.derived_key, 'hex')
if (storedKey.length !== derivedKey.length) {
return false
}
return timingSafeEqual(storedKey, derivedKey)
}
function normalizeLoopbackHost(host) {
const normalized = String(host || '').trim().toLowerCase()
if (normalized === 'localhost' || normalized === '::1') {
return '127.0.0.1'
}
if (normalized === '::') {
return '0.0.0.0'
}
return normalized
}
function resolveClientHost(host) {
const normalizedHost = normalizeLoopbackHost(host)
if (!normalizedHost || normalizedHost === '0.0.0.0') {
return '127.0.0.1'
}
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)
if (!normalizedLeft || !normalizedRight) {
return false
}
if (normalizedLeft === normalizedRight) {
return true
}
return normalizedLeft === '0.0.0.0' || normalizedRight === '0.0.0.0'
}
function serializeEnvValue(value) {
const stringValue = value == null ? '' : String(value)
if (stringValue === '') {
return ''
}
if (/^[A-Za-z0-9_./:-]+$/u.test(stringValue)) {
return stringValue
}
return `'${stringValue.replace(/'/gu, `'\\''`)}'`
}
function updateEnvFile(updates) {
ensureEnvFile()
let content = fs.readFileSync(envFile, 'utf8')
const existingLines = content ? content.split(/\r?\n/u) : []
const remainingKeys = new Set(Object.keys(updates))
const nextLines = existingLines.map((line) => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
return line
}
const separatorIndex = line.indexOf('=')
if (separatorIndex === -1) {
return line
}
const key = line.slice(0, separatorIndex).trim()
if (!remainingKeys.has(key)) {
return line
}
remainingKeys.delete(key)
return `${key}=${serializeEnvValue(updates[key])}`
})
for (const key of remainingKeys) {
nextLines.push(`${key}=${serializeEnvValue(updates[key])}`)
}
content = `${nextLines.join('\n').replace(/\n+$/u, '')}\n`
fs.writeFileSync(envFile, content, 'utf8')
}
function buildDatabaseUrl(payload) {
const username = encodeURIComponent(payload.postgres_user)
const password = encodeURIComponent(payload.postgres_password)
return `postgresql+psycopg://${username}:${password}@${payload.postgres_host}:${payload.postgres_port}/${payload.postgres_db}`
}
function buildCorsOrigins(payload) {
const webHost = String(payload.web_host || '').trim()
const webPort = String(payload.web_port || '').trim()
const origins = new Set()
const normalizedHost = normalizeLoopbackHost(webHost)
if (normalizedHost === '0.0.0.0') {
origins.add(`http://127.0.0.1:${webPort}`)
origins.add(`http://localhost:${webPort}`)
origins.add(`http://0.0.0.0:${webPort}`)
} else {
origins.add(`http://${webHost}:${webPort}`)
if (normalizedHost === '127.0.0.1') {
origins.add(`http://127.0.0.1:${webPort}`)
origins.add(`http://localhost:${webPort}`)
}
}
return JSON.stringify([...origins])
}
function buildApiBaseUrl(payload, currentEnv) {
const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1'
return 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',
VITE_COMPANY_NAME: String(payload.company_name || '').trim(),
VITE_COMPANY_CODE: String(payload.company_code || '').trim(),
VITE_ADMIN_EMAIL: String(payload.admin_email || '').trim(),
VITE_WEB_HOST: String(payload.web_host || '').trim(),
VITE_WEB_PORT: String(payload.web_port || '').trim(),
VITE_SERVER_HOST: String(payload.server_host || '').trim(),
VITE_SERVER_PORT: String(payload.server_port || '').trim(),
VITE_POSTGRES_HOST: String(payload.postgres_host || '').trim(),
VITE_POSTGRES_PORT: String(payload.postgres_port || '').trim(),
VITE_POSTGRES_DB: String(payload.postgres_db || '').trim(),
VITE_POSTGRES_USER: String(payload.postgres_user || '').trim(),
VITE_REDIS_URL: String(payload.redis_url || '').trim(),
VITE_API_BASE_URL: apiBaseUrl
}
}
function normalizeState(env) {
const adminConfigured = Boolean(readAdminSecret())
return {
initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured,
company: {
name: env.COMPANY_NAME || '',
code: env.COMPANY_CODE || '',
admin_email: env.ADMIN_EMAIL || ''
},
admin: {
configured: adminConfigured
},
web: {
host: env.WEB_HOST || '0.0.0.0',
port: Number(env.WEB_PORT || 5173)
},
server: {
host: env.SERVER_HOST || '0.0.0.0',
port: Number(env.SERVER_PORT || 8000)
},
database: {
driver: 'postgresql',
host: env.POSTGRES_HOST || '127.0.0.1',
port: Number(env.POSTGRES_PORT || 5432),
name: env.POSTGRES_DB || 'x_financial',
username: env.POSTGRES_USER || 'postgres',
password_configured: Boolean(env.POSTGRES_PASSWORD)
},
redis: {
enabled: Boolean(env.REDIS_URL),
url: env.REDIS_URL || ''
}
}
}
async function readJsonBody(req) {
const chunks = []
for await (const chunk of req) {
chunks.push(chunk)
}
const raw = Buffer.concat(chunks).toString('utf8')
return raw ? JSON.parse(raw) : {}
}
function sendJson(res, statusCode, payload) {
res.statusCode = statusCode
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify(payload))
}
function validateRuntimePayload(payload) {
const fields = [
['server_host', 'Server Host']
]
for (const [field, label] of fields) {
if (!String(payload[field] ?? '').trim()) {
return `请填写 ${label}`
}
}
const portFields = [
['server_port', 'Server Port']
]
for (const [field, label] of portFields) {
const value = Number(payload[field])
if (!Number.isInteger(value) || value < 1 || value > 65535) {
return `${label} 必须在 1 到 65535 之间。`
}
}
return ''
}
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: 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
}
}
function validateDatabasePayload(payload) {
const fields = [
['postgres_host', 'PostgreSQL Host'],
['postgres_db', '数据库名称'],
['postgres_user', '数据库用户']
]
for (const [field, label] of fields) {
if (!String(payload[field] ?? '').trim()) {
return `请填写 ${label}`
}
}
const port = Number(payload.postgres_port)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return 'PostgreSQL Port 必须在 1 到 65535 之间。'
}
if (!String(payload.postgres_password || '').length) {
return '请填写数据库密码。'
}
return ''
}
function validateIdentityPayload(payload) {
const companyName = String(payload.company_name || '').trim()
const adminEmail = String(payload.admin_email || '').trim()
const adminUsername = String(payload.admin_username || '').trim()
const adminPassword = String(payload.admin_password || '')
const adminPasswordConfirm = String(payload.admin_password_confirm || '')
if (companyName.length < 2) {
return '企业名称至少 2 个字符。'
}
if (!adminEmail) {
return '请填写管理员邮箱。'
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(adminEmail)) {
return '管理员邮箱格式不正确。'
}
if (adminUsername.length < 4) {
return '管理员账号至少 4 位。'
}
if (!/^[A-Za-z0-9._@-]+$/u.test(adminUsername)) {
return '管理员账号仅允许字母、数字、点、下划线、中划线和 @。'
}
if (adminPassword.length < 5) {
return '管理员密码当前至少 5 位。'
}
if (adminPassword !== adminPasswordConfirm) {
return '两次输入的管理员密码不一致。'
}
return ''
}
function validateSetupPayload(payload) {
return validateIdentityPayload(payload) || validateRuntimePayload(payload) || validateDatabasePayload(payload)
}
async function assertPortAvailable(host, port) {
await new Promise((resolve, reject) => {
const tester = net.createServer()
tester.once('error', (error) => {
tester.close()
reject(error)
})
tester.once('listening', () => {
tester.close(() => resolve())
})
tester.listen(port, host)
})
}
async function testRuntimePorts(payload) {
const webPort = Number(payload.web_port)
const serverPort = Number(payload.server_port)
const webHost = String(payload.web_host || '').trim()
const serverHost = String(payload.server_host || '').trim()
if (webPort === serverPort && hostsConflict(webHost, serverHost)) {
throw new Error('Web 与 Server 不能使用同一个主机与端口组合。')
}
try {
await assertPortAvailable(serverHost, serverPort)
} catch {
throw new Error(`Server 端口 ${serverHost}:${serverPort} 已被占用。`)
}
}
async function loadPgClient() {
try {
const module = await import('pg')
return module.Client
} catch {
throw new Error('缺少 Node 侧 PostgreSQL 驱动 pgweb/node_modules/pg。请先执行 bash start.sh或进入 web 目录执行 npm install。')
}
}
async function testDatabaseConnection(payload) {
const Client = await loadPgClient()
const requestedHost = String(payload.postgres_host || '').trim()
const requestedHostNormalized = normalizeLoopbackHost(requestedHost)
const dockerPostgresHost = String(process.env.POSTGRES_HOST || '').trim()
const containerPostgresPort = Number(process.env.POSTGRES_PORT || 5432)
const shouldUseDockerPostgres =
dockerPostgresHost === 'postgres' &&
['127.0.0.1', 'localhost', '0.0.0.0', '::1', '::'].includes(requestedHostNormalized)
const effectiveHost =
shouldUseDockerPostgres ? 'postgres' : requestedHost
const effectivePort =
shouldUseDockerPostgres ? containerPostgresPort : Number(payload.postgres_port)
const client = new Client({
host: effectiveHost,
port: effectivePort,
database: String(payload.postgres_db || '').trim(),
user: String(payload.postgres_user || '').trim(),
password: String(payload.postgres_password || ''),
connectionTimeoutMillis: 5000
})
try {
await client.connect()
await client.query('SELECT 1')
} finally {
await client.end().catch(() => {})
}
}
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 freshEnv = { ...process.env }
const envFileContent = fs.readFileSync(envFile, 'utf-8')
for (const line of envFileContent.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx < 0) continue
const key = trimmed.slice(0, eqIdx).trim()
const val = trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, '')
freshEnv[key] = val
}
const child = spawn('bash', [path.join(rootDir, 'start.sh'), 'server'], {
cwd: rootDir,
detached: true,
env: freshEnv,
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') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const username = String(payload.username || '').trim()
const password = String(payload.password || '')
if (!username || !password) {
sendJson(res, 400, { detail: '请输入管理员账号和密码。' })
return
}
const passed = verifyAdminCredentials(username, password)
if (!passed) {
sendJson(res, 401, { detail: '管理员账号或密码错误。' })
return
}
sendJson(res, 200, {
ok: true,
detail: '登录成功。',
user: {
username
}
})
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '管理员登录校验失败。'
})
}
})
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
try {
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = resolveRuntimePayload(await readJsonBody(req), readEnvState())
const validationError = validateRuntimePayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testRuntimePorts(payload)
sendJson(res, 200, { ok: true, detail: 'Server 端口占用检测通过。' })
} catch (error) {
sendJson(res, 400, {
ok: false,
detail: error instanceof Error ? error.message : '端口占用检测失败。'
})
}
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '运行端口检测服务异常。'
})
}
})
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
try {
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const validationError = validateDatabasePayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testDatabaseConnection(payload)
sendJson(res, 200, { ok: true, detail: '数据库连接检测通过。' })
} catch (error) {
sendJson(res, 400, {
ok: false,
detail: error instanceof Error ? error.message : '数据库连接检测失败。'
})
}
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '数据库检测服务异常。'
})
}
})
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') {
sendJson(res, 200, normalizeState(readEnvState()))
return
}
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const currentEnv = readEnvState()
const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv)
const validationError = validateSetupPayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testRuntimePorts(payload)
await testDatabaseConnection(payload)
} catch (error) {
sendJson(res, 400, {
detail: error instanceof Error ? error.message : '初始化校验失败。'
})
return
}
persistAdminCredentials(payload)
const apiBaseUrl = buildApiBaseUrl(payload, currentEnv)
updateEnvFile({
SETUP_COMPLETED: 'true',
COMPANY_NAME: String(payload.company_name || '').trim(),
COMPANY_CODE: String(payload.company_code || '').trim(),
ADMIN_EMAIL: String(payload.admin_email || '').trim(),
WEB_HOST: String(payload.web_host || '').trim(),
WEB_PORT: String(payload.web_port || '').trim(),
SERVER_HOST: String(payload.server_host || '').trim(),
SERVER_PORT: String(payload.server_port || '').trim(),
POSTGRES_HOST: String(payload.postgres_host || '').trim(),
POSTGRES_PORT: String(payload.postgres_port || '').trim(),
POSTGRES_DB: String(payload.postgres_db || '').trim(),
POSTGRES_USER: String(payload.postgres_user || '').trim(),
POSTGRES_PASSWORD: String(payload.postgres_password || ''),
DATABASE_URL: buildDatabaseUrl(payload),
REDIS_URL: String(payload.redis_url || '').trim(),
CORS_ORIGINS: buildCorsOrigins(payload),
VITE_API_BASE_URL: apiBaseUrl,
...buildClientEnvUpdates(payload, apiBaseUrl)
})
sendJson(res, 201, normalizeState(readEnvState()))
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '初始化写入失败。'
})
}
})
}
}
}
export default defineConfig({
envDir: '..',
server: {
allowedHosts: ['www.caoxiaozhu.com', 'caoxiaozhu.com'],
watch: {
...(preferPollingWatcher
? {
// Docker bind mounts can miss fs events for Vue SFCs, which leaves Vite serving stale templates.
usePolling: true,
interval: 250
}
: {}),
ignored: [
envFile,
envExampleFile,
path.join(rootDir, 'server', 'logs', '**')
]
},
proxy: {
'/api': {
target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`,
changeOrigin: true
}
}
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return undefined
}
const normalizedId = id.replace(/\\/g, '/')
if (
normalizedId.includes('/node_modules/vue/') ||
normalizedId.includes('/node_modules/@vue/') ||
normalizedId.includes('/node_modules/vue-router/')
) {
return 'vendor-vue'
}
if (normalizedId.includes('element-plus') || normalizedId.includes('@element-plus')) {
return 'vendor-element-plus'
}
if (normalizedId.includes('echarts') || normalizedId.includes('zrender')) {
return 'vendor-echarts'
}
if (normalizedId.includes('@antv/g6')) {
return 'vendor-g6'
}
if (normalizedId.includes('chart.js') || normalizedId.includes('vue-chartjs')) {
return 'vendor-chartjs'
}
if (normalizedId.includes('markdown-it')) {
return 'vendor-markdown'
}
if (normalizedId.includes('@vueuse')) {
return 'vendor-vueuse'
}
return 'vendor'
}
}
}
},
plugins: [vue(), localSetupPlugin()]
})