726 lines
35 KiB
HTML
726 lines
35 KiB
HTML
<!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>
|