Files
kaidi/frontend/src/pages/setup/index.vue
T
Qiufeng 10d2e2f0d1
Release / release (push) Canceled after 0s
fix: leave setup wizard after completion
2026-08-18 11:38:28 +08:00

563 lines
18 KiB
Vue
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.
<template>
<main class="setup-page">
<header class="setup-brand">财务系统</header>
<section class="setup-panel" aria-labelledby="setup-title">
<div class="setup-heading">
<div>
<span class="eyebrow">首次安装</span>
<h1 id="setup-title">安装向导</h1>
</div>
<t-tag theme="primary" variant="light">一次性配置</t-tag>
</div>
<t-steps :current="currentStep" class="setup-steps">
<t-step-item title="配置数据库" />
<t-step-item title="创建管理员" />
<t-step-item title="完成安装" />
</t-steps>
<t-loading v-if="initializing" class="setup-loading" text="正在读取安装状态" />
<template v-else>
<t-alert v-if="errorMessage" class="setup-alert" theme="error" :close-btn="false">
{{ errorMessage }}
</t-alert>
<section v-if="currentStep === 0" class="setup-content" aria-label="配置数据库">
<t-form
ref="databaseFormRef"
:data="databaseForm"
:rules="databaseRules"
label-align="top"
@submit="testConnection"
>
<div class="form-grid">
<t-form-item class="form-span" label="数据库类型" name="databaseType">
<t-radio-group v-model="databaseForm.databaseType" variant="default-filled">
<t-radio-button value="MYSQL">MySQL 8.4</t-radio-button>
<t-tooltip content="当前业务迁移和查询使用 MySQL 方言,PostgreSQL 适配完成后开放">
<span><t-radio-button value="POSTGRESQL" disabled>PostgreSQL</t-radio-button></span>
</t-tooltip>
</t-radio-group>
</t-form-item>
<t-form-item for="setup-db-host-input" label="数据库地址" name="host">
<t-input
id="setup-db-host"
v-model="databaseForm.host"
v-native-input-id="'setup-db-host-input'"
placeholder="127.0.0.1"
autocomplete="off"
>
<template #prefix-icon><t-icon name="server" /></template>
</t-input>
</t-form-item>
<t-form-item for="setup-db-port-input" label="端口" name="port">
<t-input-number
id="setup-db-port"
v-model="databaseForm.port"
v-native-input-id="'setup-db-port-input'"
:min="1"
:max="65535"
theme="normal"
/>
</t-form-item>
<t-form-item for="setup-db-name-input" label="数据库名称" name="database">
<t-input
id="setup-db-name"
v-model="databaseForm.database"
v-native-input-id="'setup-db-name-input'"
placeholder="kaidi_finance"
autocomplete="off"
/>
</t-form-item>
<t-form-item for="setup-db-username-input" label="数据库账号" name="username">
<t-input
id="setup-db-username"
v-model="databaseForm.username"
v-native-input-id="'setup-db-username-input'"
autocomplete="username"
/>
</t-form-item>
<t-form-item for="setup-db-password-input" label="数据库密码" name="password">
<t-input
id="setup-db-password"
v-model="databaseForm.password"
v-native-input-id="'setup-db-password-input'"
type="password"
autocomplete="current-password"
/>
</t-form-item>
<t-form-item for="setup-code-input" label="安装码" name="setupCode">
<t-input
id="setup-code"
v-model="databaseForm.setupCode"
v-native-input-id="'setup-code-input'"
type="password"
autocomplete="one-time-code"
/>
</t-form-item>
</div>
<t-alert v-if="connectionResult" class="connection-result" theme="success" :close-btn="false">
{{ connectionResult.message }}({{ connectionResult.serverVersion }})
</t-alert>
<div class="setup-actions">
<t-button theme="primary" variant="outline" type="submit" :loading="testingConnection">
<template #icon><t-icon name="link" /></template>
测试连接
</t-button>
<t-button theme="primary" :disabled="!connectionVerified" @click="currentStep = 1">
下一步
<template #icon><t-icon name="chevron-right" /></template>
</t-button>
</div>
</t-form>
</section>
<section v-else-if="currentStep === 1" class="setup-content" aria-label="创建管理员">
<t-form ref="adminFormRef" :data="adminForm" :rules="adminRules" label-align="top" @submit="finishSetup">
<div class="form-grid">
<t-form-item for="setup-admin-username-input" label="管理员账号" name="adminUsername">
<t-input
id="setup-admin-username"
v-model="adminForm.adminUsername"
v-native-input-id="'setup-admin-username-input'"
autocomplete="username"
>
<template #prefix-icon><t-icon name="user" /></template>
</t-input>
</t-form-item>
<t-form-item for="setup-admin-display-name-input" label="显示名称" name="adminDisplayName">
<t-input
id="setup-admin-display-name"
v-model="adminForm.adminDisplayName"
v-native-input-id="'setup-admin-display-name-input'"
autocomplete="name"
/>
</t-form-item>
<t-form-item for="setup-admin-password-input" label="管理员密码" name="adminPassword">
<t-input
id="setup-admin-password"
v-model="adminForm.adminPassword"
v-native-input-id="'setup-admin-password-input'"
type="password"
autocomplete="new-password"
/>
</t-form-item>
<t-form-item
for="setup-admin-password-confirmation-input"
label="确认密码"
name="adminPasswordConfirmation"
>
<t-input
id="setup-admin-password-confirmation"
v-model="adminForm.adminPasswordConfirmation"
v-native-input-id="'setup-admin-password-confirmation-input'"
type="password"
autocomplete="new-password"
/>
</t-form-item>
</div>
<t-alert v-if="completing" class="setup-progress" theme="info" :close-btn="false">
{{ completionProgressMessage }}
</t-alert>
<div class="setup-actions">
<t-button variant="outline" :disabled="completing" @click="currentStep = 0">
<template #icon><t-icon name="chevron-left" /></template>
上一步
</t-button>
<t-popconfirm content="确认后将初始化数据库并锁定安装向导。" @confirm="confirmSetup">
<t-button theme="primary" :loading="completing">
<template #icon><t-icon name="check" /></template>
完成安装
</t-button>
</t-popconfirm>
</div>
</t-form>
</section>
<section v-else class="setup-complete" aria-live="polite">
<t-icon class="complete-icon" name="check-circle-filled" />
<h2>安装完成</h2>
<p class="completion-message">{{ completionMessage }}</p>
<t-button theme="primary" @click="openLogin">
<template #icon><t-icon name="login" /></template>
进入登录页
</t-button>
<p>{{ countdown }} 秒后自动进入登录页</p>
</section>
</template>
</section>
</main>
</template>
<script setup lang="ts">
import type { FormInstanceFunctions, FormRule, SubmitContext } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { SetupConnection, SetupDatabaseRequest } from '@/api/setup';
import { completeSetup, getSetupStatus, testSetupConnection } from '@/api/setup';
defineOptions({ name: 'SetupWizardPage' });
function syncNativeInput(element: HTMLElement, binding: { value: string }) {
const input = element.querySelector('input');
if (input && binding.value) input.id = binding.value;
}
const vNativeInputId = {
mounted: syncNativeInput,
updated: syncNativeInput,
};
const router = useRouter();
const databaseFormRef = ref<FormInstanceFunctions>();
const adminFormRef = ref<FormInstanceFunctions>();
const currentStep = ref(0);
const initializing = ref(true);
const testingConnection = ref(false);
const completing = ref(false);
const errorMessage = ref('');
const connectionResult = ref<SetupConnection | null>(null);
const verifiedFingerprint = ref('');
const completionMessage = ref('系统正在重启,请稍候。');
const completionProgressMessage = ref('正在初始化数据库结构并创建管理员,请稍候。');
const countdown = ref(10);
let countdownTimer: number | undefined;
let unmounted = false;
const SETUP_CONFIRMATION_TIMEOUT_MS = 60_000;
const SETUP_CONFIRMATION_INTERVAL_MS = 2_000;
const databaseForm = reactive<SetupDatabaseRequest>({
setupCode: '',
databaseType: 'MYSQL',
host: '127.0.0.1',
port: 3306,
database: 'kaidi_finance',
username: 'kaidi',
password: '',
});
const adminForm = reactive({
adminUsername: 'admin',
adminDisplayName: '系统管理员',
adminPassword: '',
adminPasswordConfirmation: '',
});
const databaseRules: Record<string, FormRule[]> = {
databaseType: [{ required: true, message: '请选择数据库类型', type: 'error' }],
host: [
{ required: true, message: '请输入数据库地址', type: 'error' },
{ pattern: /^[A-Z0-9.-]+$/i, message: '数据库地址格式不正确', type: 'error' },
],
port: [{ required: true, message: '请输入数据库端口', type: 'error' }],
database: [
{ required: true, message: '请输入数据库名称', type: 'error' },
{ pattern: /^[\w$]+$/, message: '数据库名称只能包含字母、数字、下划线或 $', type: 'error' },
],
username: [{ required: true, message: '请输入数据库账号', type: 'error' }],
password: [{ required: true, message: '请输入数据库密码', type: 'error' }],
setupCode: [{ required: true, message: '请输入安装码', type: 'error' }],
};
const adminRules = computed<Record<string, FormRule[]>>(() => ({
adminUsername: [
{ required: true, message: '请输入管理员账号', type: 'error' },
{ pattern: /^[\w.-]{3,64}$/, message: '账号需为 3-64 位字母、数字、点、下划线或短横线', type: 'error' },
],
adminDisplayName: [{ required: true, message: '请输入显示名称', type: 'error' }],
adminPassword: [
{ required: true, message: '请输入管理员密码', type: 'error' },
{ min: 12, message: '管理员密码至少 12 位', type: 'error' },
],
adminPasswordConfirmation: [
{ required: true, message: '请再次输入管理员密码', type: 'error' },
{
validator: (value) => value === adminForm.adminPassword,
message: '两次输入的管理员密码不一致',
type: 'error',
},
],
}));
const databaseFingerprint = computed(() => JSON.stringify(databaseForm));
const connectionVerified = computed(
() =>
Boolean(connectionResult.value?.successful && connectionResult.value.schemaReady) &&
verifiedFingerprint.value === databaseFingerprint.value,
);
watch(databaseFingerprint, () => {
if (verifiedFingerprint.value !== databaseFingerprint.value) connectionResult.value = null;
});
async function testConnection(context?: SubmitContext) {
if (context && context.validateResult !== true) return;
if (!context && databaseFormRef.value && (await databaseFormRef.value.validate()) !== true) return;
testingConnection.value = true;
errorMessage.value = '';
try {
const result = await testSetupConnection({ ...databaseForm });
connectionResult.value = result;
verifiedFingerprint.value = databaseFingerprint.value;
MessagePlugin.success('数据库连接验证通过');
} catch (error) {
connectionResult.value = null;
errorMessage.value = (error as Error).message || '数据库连接验证失败';
} finally {
testingConnection.value = false;
}
}
async function finishSetup(context?: SubmitContext) {
if (context && context.validateResult !== true) return;
if (!context && adminFormRef.value && (await adminFormRef.value.validate()) !== true) return;
if (!connectionVerified.value) {
currentStep.value = 0;
errorMessage.value = '数据库配置已变化,请重新测试连接';
return;
}
completing.value = true;
completionProgressMessage.value = '正在初始化数据库结构并创建管理员,请稍候。';
errorMessage.value = '';
try {
const result = await completeSetup({ ...databaseForm, ...adminForm });
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
showCompleted(result.message, result.restartAfterSeconds);
} catch (error) {
const requestError = error as Error & { status?: number };
if (requestError.status === undefined || requestError.status >= 500) {
completionProgressMessage.value = '连接暂时中断,正在确认安装结果。';
if (await waitForSetupCompletion()) {
showCompleted('安装已完成,系统正在切换到正式模式', 10);
return;
}
errorMessage.value = '安装结果暂未确认,请稍后重新打开安装页面;若问题持续,请检查服务日志。';
} else {
errorMessage.value = requestError.message || '安装初始化失败';
}
} finally {
completing.value = false;
}
}
async function waitForSetupCompletion() {
const deadline = Date.now() + SETUP_CONFIRMATION_TIMEOUT_MS;
while (Date.now() < deadline) {
if (unmounted) return false;
try {
const status = await getSetupStatus();
if (!status.required && (status.ready || status.locked)) return true;
} catch {
// A short connection failure is expected while systemd restarts the service.
}
await new Promise((resolve) => window.setTimeout(resolve, SETUP_CONFIRMATION_INTERVAL_MS));
}
return false;
}
function showCompleted(message: string, restartAfterSeconds: number) {
completionMessage.value = message;
countdown.value = Math.max(1, restartAfterSeconds || 10);
currentStep.value = 2;
startCountdown();
}
function confirmSetup() {
void finishSetup();
}
function startCountdown() {
window.clearInterval(countdownTimer);
countdownTimer = window.setInterval(() => {
countdown.value = Math.max(0, countdown.value - 1);
if (countdown.value === 0) openLogin();
}, 1000);
}
function openLogin() {
window.clearInterval(countdownTimer);
countdown.value = 0;
completionMessage.value = '正在进入登录页。';
// A full navigation clears the setup-mode SPA state retained across the
// service restart. The login route guard performs the final setup check.
window.location.replace('/login');
}
onMounted(async () => {
try {
const status = await getSetupStatus();
if (!status.required) {
await router.replace('/login');
}
} catch (error) {
const statusCode = (error as { response?: { status?: number } }).response?.status;
if (statusCode === 404) await router.replace('/login');
errorMessage.value = (error as Error).message || '安装状态读取失败';
} finally {
initializing.value = false;
}
});
onBeforeUnmount(() => {
unmounted = true;
window.clearInterval(countdownTimer);
});
</script>
<style scoped>
.setup-page {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
padding: 24px clamp(16px, 4vw, 48px) 40px;
background: var(--td-bg-color-page);
}
.setup-brand {
color: var(--td-text-color-primary);
font-size: 18px;
font-weight: 600;
}
.setup-panel {
place-self: center;
width: min(760px, 100%);
min-width: 0;
padding: 32px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 8px;
box-shadow: var(--td-shadow-1);
}
.setup-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
color: var(--td-text-color-secondary);
font-size: 13px;
}
h1 {
margin: 4px 0 0;
font-size: 24px;
line-height: 32px;
}
.setup-steps {
margin: 28px 0 32px;
}
.setup-loading {
min-height: 280px;
}
.setup-alert,
.connection-result {
margin-bottom: 20px;
}
.setup-content {
min-width: 0;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 20px;
}
.form-span {
grid-column: 1 / -1;
}
.setup-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.setup-progress {
margin-top: 20px;
}
.setup-complete {
min-height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.setup-complete p {
margin: 0;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.complete-icon {
color: var(--td-success-color);
font-size: 56px;
}
.setup-complete h2 {
margin: 16px 0 8px;
font-size: 24px;
line-height: 32px;
}
.setup-complete .completion-message {
margin-bottom: 20px;
}
@media (width <= 640px) {
.setup-page {
padding: 16px 12px 24px;
}
.setup-panel {
align-self: start;
margin-top: 20px;
padding: 20px 16px;
}
.setup-heading {
align-items: center;
}
.setup-steps {
margin: 24px 0;
}
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
.form-span {
grid-column: auto;
}
.setup-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.setup-actions :deep(.t-button) {
width: 100%;
}
}
</style>