563 lines
18 KiB
Vue
563 lines
18 KiB
Vue
<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>
|