feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
+513
View File
@@ -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>