This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
<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>
|
||||
|
||||
<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 countdown = ref(10);
|
||||
let countdownTimer: number | undefined;
|
||||
|
||||
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;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const result = await completeSetup({ ...databaseForm, ...adminForm });
|
||||
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
|
||||
completionMessage.value = result.message;
|
||||
countdown.value = Math.max(1, result.restartAfterSeconds || 10);
|
||||
currentStep.value = 2;
|
||||
startCountdown();
|
||||
} catch (error) {
|
||||
errorMessage.value = (error as Error).message || '安装初始化失败';
|
||||
} finally {
|
||||
completing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSetup() {
|
||||
void finishSetup();
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
window.clearInterval(countdownTimer);
|
||||
countdownTimer = window.setInterval(() => {
|
||||
countdown.value -= 1;
|
||||
if (countdown.value <= 0) openLogin();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openLogin() {
|
||||
window.clearInterval(countdownTimer);
|
||||
window.location.assign('/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(() => 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 {
|
||||
align-self: center;
|
||||
justify-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-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>
|
||||
Reference in New Issue
Block a user