feat: 添加安装向导功能
- 后端:安装状态检测API、数据库连接测试、配置文件生成 - 后端:支持SQLite/MySQL数据库选择 - 后端:JWT密钥自动生成或手动设置 - 前端:安装向导界面(数据库配置、安全配置、管理员账号) - 前端:路由守卫检测安装状态,未安装自动跳转安装页面
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronRight, Database, Loader2, RefreshCw, Server, ShieldCheck, User } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const testing = ref(false)
|
||||
const step = ref(1)
|
||||
const installStatus = ref<any>(null)
|
||||
const checkingStatus = ref(true)
|
||||
|
||||
const form = ref({
|
||||
db_type: 'sqlite',
|
||||
db_host: 'localhost',
|
||||
db_port: '3306',
|
||||
db_name: 'verification_platform',
|
||||
db_username: 'root',
|
||||
db_password: '',
|
||||
jwt_secret: '',
|
||||
jwt_auto: true,
|
||||
admin_user: 'admin',
|
||||
admin_pass: '',
|
||||
admin_email: '',
|
||||
use_redis: false,
|
||||
redis_host: 'localhost',
|
||||
redis_port: '6379',
|
||||
redis_password: '',
|
||||
redis_db: 0,
|
||||
})
|
||||
|
||||
const dbTypes = [
|
||||
{ value: 'sqlite', label: 'SQLite', desc: '轻量级,无需额外服务,适合小型部署' },
|
||||
{ value: 'mysql', label: 'MySQL', desc: '高性能,适合生产环境' },
|
||||
]
|
||||
|
||||
async function checkInstallStatus() {
|
||||
checkingStatus.value = true
|
||||
try {
|
||||
const data = await api.get<any>('/api/install/status')
|
||||
installStatus.value = data
|
||||
if (data?.installed) {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('检查安装状态失败:', error)
|
||||
}
|
||||
finally {
|
||||
checkingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testDatabase() {
|
||||
testing.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
db_type: form.value.db_type,
|
||||
}
|
||||
if (form.value.db_type === 'mysql') {
|
||||
payload.host = form.value.db_host
|
||||
payload.port = form.value.db_port
|
||||
payload.name = form.value.db_name
|
||||
payload.username = form.value.db_username
|
||||
payload.password = form.value.db_password
|
||||
}
|
||||
const data = await api.post<any>('/api/install/test-db', payload)
|
||||
toast.success(data?.message || '数据库连接成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '数据库连接失败')
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function generateJwtSecret() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
form.value.jwt_secret = result
|
||||
form.value.jwt_auto = false
|
||||
}
|
||||
|
||||
const canProceed = computed(() => {
|
||||
if (step.value === 1) {
|
||||
if (form.value.db_type === 'sqlite') return true
|
||||
return form.value.db_host && form.value.db_port && form.value.db_name && form.value.db_username
|
||||
}
|
||||
if (step.value === 2) {
|
||||
return form.value.jwt_secret || form.value.jwt_auto
|
||||
}
|
||||
if (step.value === 3) {
|
||||
return form.value.admin_user && form.value.admin_pass && form.value.admin_pass.length >= 6
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
async function handleInstall() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
db_type: form.value.db_type,
|
||||
jwt_secret: form.value.jwt_auto ? '' : form.value.jwt_secret,
|
||||
admin_user: form.value.admin_user,
|
||||
admin_pass: form.value.admin_pass,
|
||||
admin_email: form.value.admin_email,
|
||||
use_redis: form.value.use_redis,
|
||||
}
|
||||
|
||||
if (form.value.db_type === 'mysql') {
|
||||
payload.db_host = form.value.db_host
|
||||
payload.db_port = form.value.db_port
|
||||
payload.db_name = form.value.db_name
|
||||
payload.db_username = form.value.db_username
|
||||
payload.db_password = form.value.db_password
|
||||
}
|
||||
|
||||
if (form.value.use_redis) {
|
||||
payload.redis_host = form.value.redis_host
|
||||
payload.redis_port = form.value.redis_port
|
||||
payload.redis_password = form.value.redis_password
|
||||
payload.redis_db = form.value.redis_db
|
||||
}
|
||||
|
||||
const data = await api.post<any>('/api/install/setup', payload)
|
||||
toast.success(data?.message || '安装成功')
|
||||
step.value = 4
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '安装失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (step.value < 3) {
|
||||
step.value++
|
||||
}
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkInstallStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary/5 via-background to-background">
|
||||
<div v-if="checkingStatus" class="flex items-center gap-2">
|
||||
<Loader2 class="size-5 animate-spin" />
|
||||
<span>检查安装状态...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-full max-w-2xl">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 mb-4">
|
||||
<div class="size-12 rounded-xl bg-primary flex items-center justify-center">
|
||||
<ShieldCheck class="size-7 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
系统安装向导
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
欢迎使用软件授权管理平台,请完成以下配置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-2 mb-8">
|
||||
<div
|
||||
v-for="s in 4"
|
||||
:key="s"
|
||||
class="flex items-center"
|
||||
>
|
||||
<div
|
||||
class="size-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors"
|
||||
:class="step >= s ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
|
||||
>
|
||||
<Check v-if="step > s" class="size-4" />
|
||||
<span v-else>{{ s }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="s < 4"
|
||||
class="w-12 h-0.5 mx-1"
|
||||
:class="step > s ? 'bg-primary' : 'bg-muted'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<div v-if="step === 1">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<Database class="size-5 text-primary" />
|
||||
<h2 class="text-lg font-semibold">数据库配置</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<UiLabel>数据库类型</UiLabel>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="db in dbTypes"
|
||||
:key="db.value"
|
||||
type="button"
|
||||
class="p-4 rounded-lg border-2 text-left transition-all"
|
||||
:class="form.db_type === db.value ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'"
|
||||
@click="form.db_type = db.value"
|
||||
>
|
||||
<div class="font-medium">{{ db.label }}</div>
|
||||
<div class="text-sm text-muted-foreground">{{ db.desc }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="form.db_type === 'mysql'">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_host">主机地址</UiLabel>
|
||||
<UiInput id="db_host" v-model="form.db_host" placeholder="localhost" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_port">端口</UiLabel>
|
||||
<UiInput id="db_port" v-model="form.db_port" placeholder="3306" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_name">数据库名</UiLabel>
|
||||
<UiInput id="db_name" v-model="form.db_name" placeholder="verification_platform" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_username">用户名</UiLabel>
|
||||
<UiInput id="db_username" v-model="form.db_username" placeholder="root" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_password">密码</UiLabel>
|
||||
<UiInput id="db_password" v-model="form.db_password" type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
<UiButton variant="outline" :disabled="testing" @click="testDatabase">
|
||||
<Loader2 v-if="testing" class="mr-2 size-4 animate-spin" />
|
||||
测试连接
|
||||
</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 2">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<ShieldCheck class="size-5 text-primary" />
|
||||
<h2 class="text-lg font-semibold">安全配置</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<UiLabel for="jwt_secret">JWT 密钥</UiLabel>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiCheckbox id="jwt_auto" v-model:checked="form.jwt_auto" />
|
||||
<UiLabel for="jwt_auto" class="text-sm font-normal cursor-pointer">自动生成</UiLabel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UiInput
|
||||
id="jwt_secret"
|
||||
v-model="form.jwt_secret"
|
||||
:disabled="form.jwt_auto"
|
||||
placeholder="留空则自动生成"
|
||||
class="flex-1"
|
||||
/>
|
||||
<UiButton variant="outline" :disabled="form.jwt_auto" @click="generateJwtSecret">
|
||||
<RefreshCw class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
JWT 密钥用于签名认证令牌,请妥善保管
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-lg bg-muted/50">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Server class="size-4 text-muted-foreground" />
|
||||
<span class="font-medium">Redis 缓存(可选)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<UiCheckbox id="use_redis" v-model:checked="form.use_redis" />
|
||||
<UiLabel for="use_redis" class="text-sm font-normal cursor-pointer">启用 Redis</UiLabel>
|
||||
</div>
|
||||
<template v-if="form.use_redis">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="redis_host">主机</UiLabel>
|
||||
<UiInput id="redis_host" v-model="form.redis_host" placeholder="localhost" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="redis_port">端口</UiLabel>
|
||||
<UiInput id="redis_port" v-model="form.redis_port" placeholder="6379" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<UiLabel for="redis_password">密码</UiLabel>
|
||||
<UiInput id="redis_password" v-model="form.redis_password" type="password" placeholder="可选" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 3">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<User class="size-5 text-primary" />
|
||||
<h2 class="text-lg font-semibold">管理员账号</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_user">用户名</UiLabel>
|
||||
<UiInput id="admin_user" v-model="form.admin_user" placeholder="admin" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_pass">密码</UiLabel>
|
||||
<UiInput id="admin_pass" v-model="form.admin_pass" type="password" placeholder="至少6位" />
|
||||
<p v-if="form.admin_pass && form.admin_pass.length < 6" class="text-sm text-destructive">
|
||||
密码长度至少6位
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_email">邮箱(可选)</UiLabel>
|
||||
<UiInput id="admin_email" v-model="form.admin_email" type="email" placeholder="admin@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 4">
|
||||
<div class="text-center py-8">
|
||||
<div class="size-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mx-auto mb-4">
|
||||
<Check class="size-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-2">安装完成</h2>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
系统已成功安装,您现在可以使用管理员账号登录
|
||||
</p>
|
||||
<UiButton @click="goLogin">
|
||||
前往登录
|
||||
<ChevronRight class="ml-2 size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
|
||||
<UiCardFooter v-if="step < 4" class="flex justify-between border-t p-6">
|
||||
<UiButton
|
||||
v-if="step > 1"
|
||||
variant="outline"
|
||||
@click="step--"
|
||||
>
|
||||
上一步
|
||||
</UiButton>
|
||||
<div v-else />
|
||||
|
||||
<UiButton
|
||||
v-if="step < 3"
|
||||
:disabled="!canProceed"
|
||||
@click="goNext"
|
||||
>
|
||||
下一步
|
||||
<ChevronRight class="ml-2 size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-else
|
||||
:disabled="!canProceed || loading"
|
||||
@click="handleInstall"
|
||||
>
|
||||
<Loader2 v-if="loading" class="mr-2 size-4 animate-spin" />
|
||||
{{ loading ? '安装中...' : '开始安装' }}
|
||||
</UiButton>
|
||||
</UiCardFooter>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,35 @@
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
let installChecked = false
|
||||
let isInstalled = false
|
||||
|
||||
async function checkInstallStatus(): Promise<boolean> {
|
||||
if (installChecked) return isInstalled
|
||||
try {
|
||||
const res = await fetch('/api/install/status')
|
||||
const data = await res.json()
|
||||
isInstalled = data?.data?.installed || false
|
||||
installChecked = true
|
||||
return isInstalled
|
||||
}
|
||||
catch {
|
||||
installChecked = true
|
||||
isInstalled = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouterGuard(router: Router) {
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.path === '/install') {
|
||||
return true
|
||||
}
|
||||
|
||||
const installed = await checkInstallStatus()
|
||||
if (!installed) {
|
||||
return '/install'
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const userStr = localStorage.getItem('user')
|
||||
|
||||
|
||||
Vendored
+13
-3
@@ -14,9 +14,6 @@ import type {
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
import type {
|
||||
_ExtractParamParserType,
|
||||
} from 'vue-router/experimental'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface TypesConfig {
|
||||
@@ -621,6 +618,13 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/install/': RouteRecordInfo<
|
||||
'/install/',
|
||||
'/install',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/profile/': RouteRecordInfo<
|
||||
'/profile/',
|
||||
'/profile',
|
||||
@@ -1155,6 +1159,12 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/install/index.vue': {
|
||||
routes:
|
||||
| '/install/'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/profile/index.vue': {
|
||||
routes:
|
||||
| '/profile/'
|
||||
|
||||
Reference in New Issue
Block a user