Files
ERP/oa-backend/src/installer/resources/static/index.html
T
Qiufeng 5e6df4b323
Signed Release / release (push) Failing after 32s
fix: surface install progress behind reverse proxies
2026-08-04 15:07:39 +08:00

726 lines
35 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<title>凯迪 ERP 安装向导</title>
<style>
:root {
color-scheme: light;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
color: #172033;
background: #f3f5f8;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #f3f5f8; }
button, input, select { font: inherit; letter-spacing: 0; }
button { cursor: pointer; }
.topbar {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
color: #fff;
background: #1d3557;
border-bottom: 3px solid #2f6fed;
}
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.brand-mark {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #91b3e7;
border-radius: 6px;
background: #fff;
color: #1d3557;
font-size: 13px;
font-weight: 800;
}
.brand-name { font-size: 16px; font-weight: 700; white-space: nowrap; }
.topbar-label { color: #dbe7f8; font-size: 13px; white-space: nowrap; }
.shell {
width: min(1040px, calc(100% - 40px));
margin: 36px auto 48px;
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.steps {
padding: 18px 0;
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 8px;
}
.step {
position: relative;
display: grid;
grid-template-columns: 30px 1fr;
gap: 11px;
min-height: 62px;
padding: 8px 18px;
color: #748096;
}
.step:not(:last-child)::after {
content: "";
position: absolute;
left: 32px;
top: 42px;
width: 1px;
height: 28px;
background: #d8dee8;
}
.step-index {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 1px solid #c9d1de;
border-radius: 50%;
background: #fff;
font-size: 12px;
font-weight: 700;
}
.step-copy { padding-top: 4px; }
.step-title { display: block; color: #4b586d; font-size: 14px; font-weight: 650; }
.step-state { display: block; margin-top: 4px; font-size: 12px; }
.step.active .step-index { border-color: #2f6fed; background: #2f6fed; color: #fff; }
.step.active .step-title { color: #183a72; }
.step.done .step-index { border-color: #25805a; background: #e8f5ee; color: #187149; }
.panel {
min-height: 540px;
padding: 30px 34px 28px;
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(24, 38, 61, 0.06);
}
.panel-head { padding-bottom: 20px; border-bottom: 1px solid #e8ebf0; }
h1 { margin: 0; color: #172033; font-size: 24px; line-height: 1.3; letter-spacing: 0; }
.subtitle { margin: 8px 0 0; color: #69758a; font-size: 14px; line-height: 1.65; }
.section { padding-top: 24px; }
.check-list { display: grid; gap: 10px; }
.check-row {
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #e1e6ed;
border-radius: 6px;
background: #fafbfc;
}
.check-label { font-size: 14px; font-weight: 600; }
.check-detail { margin-top: 3px; color: #778398; font-size: 12px; }
.badge {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 4px;
background: #e8f5ee;
color: #187149;
font-size: 12px;
font-weight: 650;
}
.badge.wait { background: #eef1f5; color: #667287; }
.form-grid { display: grid; grid-template-columns: 1fr 150px; gap: 18px 16px; }
.field.full { grid-column: 1 / -1; }
.field label { display: block; margin-bottom: 7px; color: #334057; font-size: 13px; font-weight: 650; }
.required::after { content: " *"; color: #bd3131; }
input, select {
width: 100%;
height: 42px;
padding: 0 12px;
border: 1px solid #cbd3df;
border-radius: 5px;
outline: none;
background: #fff;
color: #172033;
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus { border-color: #2f6fed; box-shadow: 0 0 0 3px rgba(47, 111, 237, .12); }
input.invalid { border-color: #bd3131; }
.hint { margin-top: 6px; color: #7b8799; font-size: 12px; line-height: 1.5; }
.notice {
margin-top: 18px;
padding: 12px 14px;
border: 1px solid #bcdcca;
border-radius: 6px;
background: #f0f8f4;
color: #176542;
font-size: 13px;
line-height: 1.55;
}
.notice.error { border-color: #ebc1c1; background: #fff5f5; color: #a52a2a; }
.notice.info { border-color: #c8d7ee; background: #f3f7fd; color: #315b91; }
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #e8ebf0;
}
.btn {
min-width: 104px;
height: 40px;
padding: 0 17px;
border: 1px solid #c8d0dc;
border-radius: 5px;
background: #fff;
color: #344056;
font-weight: 650;
}
.btn:hover { border-color: #8fa1ba; background: #f7f8fa; }
.btn.primary { border-color: #2f6fed; background: #2f6fed; color: #fff; }
.btn.primary:hover { border-color: #245fcf; background: #245fcf; }
.btn:disabled { cursor: not-allowed; border-color: #d7dce4; background: #e9edf2; color: #939dad; }
.summary { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dfe4ec; border-radius: 6px; background: #dfe4ec; }
.summary-row { display: grid; grid-template-columns: 148px 1fr; gap: 16px; padding: 13px 15px; background: #fff; font-size: 13px; }
.summary-key { color: #707c90; }
.summary-value { min-width: 0; overflow-wrap: anywhere; color: #263248; font-weight: 600; }
.progress-list { display: grid; gap: 12px; margin-top: 8px; }
.progress-item { display: flex; align-items: center; gap: 12px; color: #6b7689; font-size: 14px; }
.progress-dot { width: 10px; height: 10px; border: 2px solid #aab3c1; border-radius: 50%; }
.progress-item.running { color: #254e86; font-weight: 600; }
.progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; }
.progress-item.done { color: #187149; }
.progress-item.done .progress-dot { border-color: #25805a; background: #25805a; }
.install-progress { margin-top: 22px; padding-top: 18px; border-top: 1px solid #e8ebf0; }
.install-progress-head, .install-progress-meta, .install-console-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.install-progress-label { color: #334057; font-size: 13px; font-weight: 650; }
.install-progress-value { color: #1d3557; font-size: 14px; font-variant-numeric: tabular-nums; }
.install-progress-track {
height: 10px;
margin-top: 10px;
overflow: hidden;
border-radius: 4px;
background: #e5eaf1;
}
.install-progress-bar {
width: 0;
height: 100%;
background: #2f6fed;
transition: width .35s ease;
}
.install-progress-meta { margin-top: 8px; color: #707c90; font-size: 12px; }
.install-console { margin-top: 14px; overflow: hidden; border: 1px solid #28364a; border-radius: 6px; background: #182233; }
.install-console-head { min-height: 36px; padding: 0 12px; border-bottom: 1px solid #314158; background: #111a28; }
.install-console-title { color: #dce5f2; font-size: 12px; font-weight: 650; }
.install-console-state { color: #7dd3a8; font-size: 12px; }
.install-log {
height: 148px;
margin: 0;
overflow: auto;
padding: 10px 12px;
color: #c9d5e5;
font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.complete-mark {
width: 58px;
height: 58px;
display: grid;
place-items: center;
margin-bottom: 18px;
border-radius: 50%;
background: #e8f5ee;
color: #187149;
font-size: 28px;
font-weight: 800;
}
.hidden { display: none !important; }
@media (max-width: 760px) {
.topbar { height: 58px; padding: 0 18px; }
.topbar-label { display: none; }
.shell { width: calc(100% - 24px); margin: 18px auto 30px; display: block; }
.steps { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 14px; padding: 10px 4px; }
.step { display: flex; min-height: auto; padding: 4px; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
.step:not(:last-child)::after { left: calc(50% + 18px); top: 18px; width: calc(100% - 36px); height: 1px; }
.step-copy { padding: 0; }
.step-title { font-size: 11px; }
.step-state { display: none; }
.panel { min-height: 0; padding: 23px 18px 20px; }
h1 { font-size: 21px; }
.form-grid { grid-template-columns: 1fr; gap: 15px; }
.field.full { grid-column: auto; }
.summary-row { grid-template-columns: 1fr; gap: 5px; }
.actions { flex-wrap: wrap-reverse; }
.btn { flex: 1 1 120px; }
.install-progress-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
.install-log { height: 172px; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark">KD</span>
<span class="brand-name">凯迪 ERP + OA</span>
</div>
<span class="topbar-label">首次安装向导</span>
</header>
<main class="shell">
<nav class="steps" aria-label="安装步骤">
<div class="step active" data-step-nav="1"><span class="step-index">1</span><span class="step-copy"><span class="step-title">环境检查</span><span class="step-state">运行环境</span></span></div>
<div class="step" data-step-nav="2"><span class="step-index">2</span><span class="step-copy"><span class="step-title">数据库</span><span class="step-state">PostgreSQL</span></span></div>
<div class="step" data-step-nav="3"><span class="step-index">3</span><span class="step-copy"><span class="step-title">管理员</span><span class="step-state">初始账号</span></span></div>
<div class="step" data-step-nav="4"><span class="step-index">4</span><span class="step-copy"><span class="step-title">安装</span><span class="step-state">初始化系统</span></span></div>
</nav>
<section class="panel">
<div id="fatal" class="hidden">
<div class="panel-head"><h1>安装链接不可用</h1><p class="subtitle">请回到服务器终端,使用安装程序输出的完整链接重新访问。</p></div>
<div class="notice error" id="fatal-message">链接缺少一次性安装令牌。</div>
</div>
<div id="view-1">
<div class="panel-head"><h1>环境检查</h1><p class="subtitle">确认安装程序和服务器运行环境已经准备就绪。</p></div>
<div class="section check-list">
<div class="check-row"><div><div class="check-label">Java 运行环境</div><div class="check-detail" id="java-detail">正在检测</div></div><span class="badge wait" id="java-badge">检测中</span></div>
<div class="check-row"><div><div class="check-label">安装程序</div><div class="check-detail" id="installer-detail">正在读取版本</div></div><span class="badge wait" id="installer-badge">检测中</span></div>
<div class="check-row"><div><div class="check-label">生产数据库要求</div><div class="check-detail">PostgreSQL 15 或更高版本</div></div><span class="badge">支持</span></div>
<div class="check-row"><div><div class="check-label">Redis</div><div class="check-detail">当前版本没有 Redis 运行依赖</div></div><span class="badge">无需配置</span></div>
</div>
<div id="status-message" class="notice info">正在连接本机安装服务。</div>
<div class="actions"><button class="btn primary" id="start-button" disabled>开始配置</button></div>
</div>
<div id="view-2" class="hidden">
<div class="panel-head"><h1>连接 PostgreSQL</h1><p class="subtitle">填写生产数据库连接信息,并完成真实连接测试。</p></div>
<form id="database-form" class="section form-grid" autocomplete="off">
<div class="field"><label class="required" for="db-host">服务器地址</label><input id="db-host" name="host" value="127.0.0.1" maxlength="253" required></div>
<div class="field"><label class="required" for="db-port">端口</label><input id="db-port" name="port" type="number" min="1" max="65535" value="5432" required></div>
<div class="field"><label class="required" for="db-name">数据库名</label><input id="db-name" name="database" value="kaidi_erp" maxlength="63" required></div>
<div class="field"><label class="required" for="db-ssl">SSL 模式</label><select id="db-ssl" name="sslMode"><option value="prefer">prefer</option><option value="require">require</option><option value="verify-full">verify-full</option><option value="verify-ca">verify-ca</option><option value="disable">disable</option><option value="allow">allow</option></select></div>
<div class="field full"><label class="required" for="db-user">数据库账号</label><input id="db-user" name="username" maxlength="128" autocomplete="username" required></div>
<div class="field full"><label class="required" for="db-password">数据库密码</label><input id="db-password" name="password" type="password" maxlength="500" autocomplete="new-password" required><div class="hint">密码只提交给当前服务器上的安装程序,不会显示在确认页。</div></div>
</form>
<div id="db-message" class="notice info">连接测试通过后才能继续。</div>
<div class="actions"><button class="btn" data-back="1">上一步</button><button class="btn" id="test-db-button">测试连接</button><button class="btn primary" id="database-next" disabled>下一步</button></div>
</div>
<div id="view-3" class="hidden">
<div class="panel-head"><h1>设置管理员</h1><p class="subtitle">创建系统的第一个管理员账号。</p></div>
<form id="admin-form" class="section form-grid" autocomplete="off">
<div class="field full"><label class="required" for="admin-login">管理员账号</label><input id="admin-login" name="loginName" value="admin" maxlength="64" autocomplete="username" required><div class="hint">以字母开头,可使用字母、数字、点、下划线和短横线。</div></div>
<div class="field full"><label class="required" for="admin-name">管理员姓名</label><input id="admin-name" name="displayName" maxlength="100" required></div>
<div class="field"><label class="required" for="admin-password">管理员密码</label><input id="admin-password" name="password" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
<div class="field"><label class="required" for="admin-confirm">确认密码</label><input id="admin-confirm" name="confirmPassword" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
</form>
<div id="admin-message" class="notice info">密码至少 8 位,请使用仅管理员本人知道的强密码。</div>
<div class="actions"><button class="btn" data-back="2">上一步</button><button class="btn primary" id="admin-next">下一步</button></div>
</div>
<div id="view-4" class="hidden">
<div id="confirm-view">
<div class="panel-head"><h1>确认并安装</h1><p class="subtitle">确认连接目标和管理员账号。安装开始后会执行数据库迁移。</p></div>
<div class="section summary" id="summary"></div>
<div class="notice info">安装不会覆盖已经包含业务用户的数据库。检测到现有系统时会立即停止。</div>
<div class="actions"><button class="btn" data-back="3">上一步</button><button class="btn primary" id="install-button">开始安装</button></div>
</div>
<div id="installing-view" class="hidden">
<div class="panel-head"><h1>正在安装</h1><p class="subtitle">请保持当前页面打开。</p></div>
<div class="section progress-list">
<div class="progress-item running" data-progress="1"><span class="progress-dot"></span><span>验证 PostgreSQL 连接</span></div>
<div class="progress-item" data-progress="2"><span class="progress-dot"></span><span>执行数据库迁移</span></div>
<div class="progress-item" data-progress="3"><span class="progress-dot"></span><span>创建管理员账号</span></div>
<div class="progress-item" data-progress="4"><span class="progress-dot"></span><span>启动正式服务并写入安装锁</span></div>
</div>
<div class="install-progress">
<div class="install-progress-head">
<span class="install-progress-label" id="install-phase">准备安装</span>
<strong class="install-progress-value" id="install-percent">0%</strong>
</div>
<div class="install-progress-track" id="install-progress-track" role="progressbar" aria-label="安装进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="install-progress-bar" id="install-progress-bar"></div>
</div>
<div class="install-progress-meta">
<span id="install-elapsed">本页已等待 0 秒</span>
<span id="install-check">等待安装任务</span>
</div>
</div>
<div class="install-console">
<div class="install-console-head"><span class="install-console-title">安装状态</span><span class="install-console-state" id="install-console-state">运行中</span></div>
<pre class="install-log" id="install-log" aria-live="polite"></pre>
</div>
<div id="install-message" class="notice info">正在初始化,数据库规模较大时可能需要几分钟。</div>
</div>
<div id="complete-view" class="hidden">
<div class="complete-mark"></div>
<div class="panel-head"><h1>安装完成</h1><p class="subtitle">正式服务已经通过健康检查,安装向导已被删除。</p></div>
<div class="notice">管理员账号已创建,可以进入系统登录。</div>
<div class="actions"><button class="btn primary" id="enter-button">进入系统</button></div>
</div>
</div>
</section>
</main>
<script>
(() => {
const query = new URLSearchParams(location.search);
const queryToken = query.get('token') || '';
if (queryToken) sessionStorage.setItem('kaidi.setup.token', queryToken);
const token = queryToken || sessionStorage.getItem('kaidi.setup.token') || '';
if (queryToken) {
query.delete('token');
const clean = location.pathname + (query.toString() ? `?${query}` : '') + location.hash;
history.replaceState(null, '', clean);
}
const state = {
step: 1,
database: null,
databaseFingerprint: '',
administrator: null,
installing: false,
installStartedAt: 0,
installPercent: 0,
installLogEntries: [],
healthPolling: false,
};
const FORMAL_SERVICE_TIMEOUT_MS = 240000;
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
function showFatal(message) {
['#view-1', '#view-2', '#view-3', '#view-4'].forEach((id) => $(id).classList.add('hidden'));
$('#fatal').classList.remove('hidden');
$('#fatal-message').textContent = message;
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { 'Content-Type': 'application/json', 'X-Setup-Token': token, ...(options.headers || {}) },
});
let payload;
try { payload = await response.json(); } catch { throw new Error('安装服务响应无效'); }
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.data;
}
function go(step) {
state.step = step;
for (let i = 1; i <= 4; i += 1) {
$(`#view-${i}`).classList.toggle('hidden', i !== step);
const nav = $(`[data-step-nav="${i}"]`);
nav.classList.toggle('active', i === step);
nav.classList.toggle('done', i < step);
nav.querySelector('.step-index').textContent = i < step ? '✓' : String(i);
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function databaseValue() {
const data = new FormData($('#database-form'));
return {
host: String(data.get('host') || '').trim(),
port: Number(data.get('port')),
database: String(data.get('database') || '').trim(),
username: String(data.get('username') || '').trim(),
password: String(data.get('password') || ''),
sslMode: String(data.get('sslMode') || ''),
};
}
function fingerprint(database) { return JSON.stringify(database); }
function invalidateDatabaseTest() {
if (state.databaseFingerprint && fingerprint(databaseValue()) !== state.databaseFingerprint) {
state.databaseFingerprint = '';
state.database = null;
$('#database-next').disabled = true;
$('#db-message').className = 'notice info';
$('#db-message').textContent = '连接信息已修改,请重新测试。';
}
}
function administratorValue() {
const data = new FormData($('#admin-form'));
return {
loginName: String(data.get('loginName') || '').trim(),
displayName: String(data.get('displayName') || '').trim(),
password: String(data.get('password') || ''),
confirmPassword: String(data.get('confirmPassword') || ''),
};
}
function renderSummary() {
const rows = [
['PostgreSQL', `${state.database.host}:${state.database.port}`],
['数据库', state.database.database],
['数据库账号', state.database.username],
['SSL 模式', state.database.sslMode],
['管理员账号', state.administrator.loginName],
['管理员姓名', state.administrator.displayName],
];
$('#summary').replaceChildren(...rows.map(([key, value]) => {
const row = document.createElement('div');
row.className = 'summary-row';
const label = document.createElement('span');
label.className = 'summary-key';
label.textContent = key;
const content = document.createElement('span');
content.className = 'summary-value';
content.textContent = value;
row.append(label, content);
return row;
}));
}
function setProgress(index) {
$$('[data-progress]').forEach((item) => {
const value = Number(item.dataset.progress);
item.classList.toggle('done', value < index);
item.classList.toggle('running', value === index);
});
}
function formatElapsed(milliseconds) {
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
if (seconds < 60) return `${seconds} 秒`;
return `${Math.floor(seconds / 60)}${seconds % 60} 秒`;
}
function setInstallMeter(percent, phase, detail) {
const value = Math.max(state.installPercent, Math.max(0, Math.min(100, Math.round(percent))));
state.installPercent = value;
$('#install-progress-bar').style.width = `${value}%`;
$('#install-percent').textContent = `${value}%`;
$('#install-phase').textContent = phase;
$('#install-check').textContent = detail;
$('#install-elapsed').textContent = `本页已等待 ${formatElapsed(Date.now() - state.installStartedAt)}`;
const track = $('#install-progress-track');
track.setAttribute('aria-valuenow', String(value));
track.setAttribute('aria-valuetext', `${phase}${value}%`);
}
function appendInstallLog(message) {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
state.installLogEntries.push(`[${timestamp}] ${message}`);
state.installLogEntries = state.installLogEntries.slice(-16);
const output = $('#install-log');
output.textContent = state.installLogEntries.join('\n');
output.scrollTop = output.scrollHeight;
}
function beginInstallTracking(resumed) {
state.installStartedAt = Date.now();
state.installLogEntries = [];
$('#install-message').className = 'notice info';
$('#install-console-state').textContent = '运行中';
if (resumed) {
setInstallMeter(70, '恢复安装任务', '准备检查正式服务');
appendInstallLog('检测到尚未完成的安装任务,继续等待正式服务。');
} else {
setInstallMeter(5, '提交安装任务', '准备验证数据库');
appendInstallLog('安装请求已提交,正在验证 PostgreSQL 连接和权限。');
}
}
async function pollFormalService() {
if (state.healthPolling) return;
state.healthPolling = true;
if (!state.installStartedAt) beginInstallTracking(true);
setProgress(4);
setInstallMeter(Math.max(state.installPercent, 72), '启动正式服务', '等待第 1 次健康检查');
appendInstallLog('数据库初始化完成,安装器正在切换到正式服务。');
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。页面会持续显示检查进度。';
const healthStartedAt = Date.now();
const deadline = healthStartedAt + FORMAL_SERVICE_TIMEOUT_MS;
let attempt = 0;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempt += 1;
const healthElapsed = Date.now() - healthStartedAt;
const estimated = 72 + Math.floor((healthElapsed / FORMAL_SERVICE_TIMEOUT_MS) * 23);
setInstallMeter(Math.min(95, estimated), '等待正式服务健康检查', `第 ${attempt} 次检查`);
try {
const response = await fetch('/api/oa/health', { cache: 'no-store' });
const body = await response.json();
const serviceStatus = body?.data?.status || body?.status || `HTTP ${response.status}`;
const installLocked = body?.data?.installLocked ?? body?.installLocked;
if (response.ok && serviceStatus === 'UP' && (installLocked === true || installLocked === 'true')) {
setInstallMeter(100, '安装完成', '正式服务已就绪');
appendInstallLog(`健康检查第 ${attempt} 次通过,安装锁已确认。`);
$('#install-console-state').textContent = '已完成';
$$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); });
$('#installing-view').classList.add('hidden');
$('#complete-view').classList.remove('hidden');
sessionStorage.removeItem('kaidi.setup.token');
state.healthPolling = false;
return;
}
if (response.ok && serviceStatus === 'UP') {
setInstallMeter(98, '正式服务已通过健康检查', '等待写入安装锁');
appendInstallLog(`健康检查第 ${attempt} 次通过,正在等待后台写入安装锁。`);
continue;
}
appendInstallLog(`健康检查第 ${attempt} 次:服务状态 ${serviceStatus}。`);
} catch {
appendInstallLog(`健康检查第 ${attempt} 次:服务端口正在切换,继续等待。`);
}
}
state.healthPolling = false;
setInstallMeter(95, '正式服务尚未就绪', `${Math.round(FORMAL_SERVICE_TIMEOUT_MS / 1000)} 秒检查超时`);
appendInstallLog('健康检查超时,安装状态已保留,未写入完成锁。');
$('#install-console-state').textContent = '需要检查';
$('#install-message').className = 'notice error';
$('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。';
}
$('#start-button').addEventListener('click', () => go(2));
$$('[data-back]').forEach((button) => button.addEventListener('click', () => go(Number(button.dataset.back))));
$('#database-form').addEventListener('input', invalidateDatabaseTest);
$('#database-form').addEventListener('change', invalidateDatabaseTest);
$('#test-db-button').addEventListener('click', async () => {
const form = $('#database-form');
if (!form.reportValidity()) return;
const button = $('#test-db-button');
const database = databaseValue();
button.disabled = true;
button.textContent = '测试中';
$('#db-message').className = 'notice info';
$('#db-message').textContent = '正在连接 PostgreSQL 并检查 pg_trgm 扩展。';
try {
const result = await api('/api/install/test-database', { method: 'POST', body: JSON.stringify(database) });
state.database = database;
state.databaseFingerprint = fingerprint(database);
$('#database-next').disabled = false;
$('#db-message').className = 'notice';
$('#db-message').textContent = `${result.message}${result.postgresVersion}`;
} catch (error) {
state.database = null;
state.databaseFingerprint = '';
$('#database-next').disabled = true;
$('#db-message').className = 'notice error';
$('#db-message').textContent = error.message;
} finally {
button.disabled = false;
button.textContent = '测试连接';
}
});
$('#database-next').addEventListener('click', () => {
if (!state.database || fingerprint(databaseValue()) !== state.databaseFingerprint) return invalidateDatabaseTest();
go(3);
});
$('#admin-next').addEventListener('click', () => {
const form = $('#admin-form');
if (!form.reportValidity()) return;
const administrator = administratorValue();
if (administrator.password !== administrator.confirmPassword) {
$('#admin-message').className = 'notice error';
$('#admin-message').textContent = '两次输入的管理员密码不一致。';
return;
}
state.administrator = {
loginName: administrator.loginName,
displayName: administrator.displayName,
password: administrator.password,
};
$('#admin-message').className = 'notice info';
$('#admin-message').textContent = '管理员信息已填写。';
renderSummary();
go(4);
});
$('#install-button').addEventListener('click', async () => {
if (state.installing || !state.database || !state.administrator) return;
state.installing = true;
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
beginInstallTracking(false);
setProgress(1);
let migrationTimer;
try {
const request = { database: state.database, administrator: state.administrator };
migrationTimer = setTimeout(() => {
setProgress(2);
setInstallMeter(28, '执行数据库初始化', '迁移结构和基础数据');
appendInstallLog('数据库连接验证通过,正在执行结构迁移和基础数据初始化。');
}, 700);
try {
await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) });
} catch (error) {
// The installer intentionally exits immediately after committing. A
// browser may see the TCP connection close before the JSON response
// is flushed; the formal health probe is the authoritative result.
if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error;
}
clearTimeout(migrationTimer);
setProgress(3);
setInstallMeter(68, '创建管理员并切换服务', '数据库初始化已完成');
appendInstallLog('数据库迁移和管理员初始化已提交,准备启动正式服务。');
state.administrator.password = '';
state.database.password = '';
await pollFormalService();
} catch (error) {
clearTimeout(migrationTimer);
state.installing = false;
state.healthPolling = false;
setInstallMeter(state.installPercent, '安装已中断', '请处理错误后重试');
appendInstallLog(`安装中断:${error.message}`);
$('#install-console-state').textContent = '已中断';
$('#install-message').className = 'notice error';
$('#install-message').textContent = error.message;
const actions = document.createElement('div');
actions.className = 'actions';
const back = document.createElement('button');
back.className = 'btn';
back.textContent = '返回检查';
back.addEventListener('click', () => location.reload());
actions.append(back);
$('#installing-view').append(actions);
}
});
$('#enter-button').addEventListener('click', () => location.assign('/'));
async function boot() {
if (!token) return showFatal('链接缺少一次性安装令牌。请使用服务器终端输出的完整链接。');
try {
const status = await api('/api/install/status');
if (status.locked) return showFatal('系统已经完成安装,安装向导已锁定。');
if (status.pending) {
go(4);
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
beginInstallTracking(true);
return pollFormalService();
}
$('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`;
$('#java-badge').className = 'badge';
$('#java-badge').textContent = '通过';
$('#installer-detail').textContent = `安装包版本 ${status.version}`;
$('#installer-badge').className = 'badge';
$('#installer-badge').textContent = '通过';
$('#status-message').className = 'notice';
$('#status-message').textContent = '服务器环境检查通过,可以开始配置。';
$('#start-button').disabled = false;
} catch (error) {
showFatal(error.message);
}
}
boot();
})();
</script>
</body>
</html>