feat: 添加安装向导功能

- 后端:安装状态检测API、数据库连接测试、配置文件生成
- 后端:支持SQLite/MySQL数据库选择
- 后端:JWT密钥自动生成或手动设置
- 前端:安装向导界面(数据库配置、安全配置、管理员账号)
- 前端:路由守卫检测安装状态,未安装自动跳转安装页面
This commit is contained in:
2026-05-03 11:52:34 +08:00
parent 507e00df52
commit 41fe48e128
8 changed files with 880 additions and 27 deletions
+1
View File
@@ -35,6 +35,7 @@ func setDefaults() {
viper.SetDefault("app.jwt_expire", "24h")
// 数据库配置
viper.SetDefault("database.type", "auto")
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", "3306")
viper.SetDefault("database.name", "verification_platform")
+90 -22
View File
@@ -30,15 +30,92 @@ var (
RDB *redis.Client
)
// Init 初始化数据库连接
func Init() {
initMySQL()
initDatabase()
initRedis()
}
// initMySQL 初始化MySQL连接
func initMySQL() {
// 尝试连接MySQL
func initDatabase() {
dbType := config.GetString("database.type")
if dbType == "" {
dbType = "auto"
}
switch dbType {
case "sqlite":
initSQLite()
case "mysql":
initMySQLExplicit()
default:
initAutoDetect()
}
}
func initSQLite() {
dbPath := "verification_platform.db"
absPath, err := filepath.Abs(dbPath)
if err != nil {
log.Fatal("Failed to get absolute path:", err.Error())
}
wd, err := os.Getwd()
if err == nil && filepath.Base(wd) == "cmd" {
absPath = filepath.Join(filepath.Dir(wd), "verification_platform.db")
}
var errOpen error
DB, errOpen = gorm.Open(sqlite.Open(absPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
DisableForeignKeyConstraintWhenMigrating: true,
})
if errOpen != nil {
log.Fatal("Failed to create SQLite database:", errOpen.Error())
}
log.Printf("Using SQLite database: %s\n", absPath)
if sqlDB, err := DB.DB(); err == nil {
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
}
runMigrations()
}
func initMySQLExplicit() {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local",
config.GetString("database.username"),
config.GetString("database.password"),
config.GetString("database.host"),
config.GetString("database.port"),
config.GetString("database.name"),
config.GetString("database.charset"),
)
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
log.Fatal("Failed to connect to MySQL:", err.Error())
}
log.Printf("Connected to MySQL: %s@%s:%s/%s\n",
config.GetString("database.username"),
config.GetString("database.host"),
config.GetString("database.port"),
config.GetString("database.name"))
if sqlDB, err := DB.DB(); err == nil {
sqlDB.SetMaxIdleConns(config.GetInt("database.max_idle_conns"))
sqlDB.SetMaxOpenConns(config.GetInt("database.max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour)
}
runMigrations()
}
func initAutoDetect() {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local",
config.GetString("database.username"),
config.GetString("database.password"),
@@ -54,28 +131,18 @@ func initMySQL() {
DisableForeignKeyConstraintWhenMigrating: true,
})
// 如果MySQL连接失败,创建一个SQLite数据库用于开发
if err != nil {
log.Println("Failed to connect to MySQL, using SQLite database for development:", err.Error())
log.Println("Using file database for development")
dbPath := "verification_platform.db"
absPath, err := filepath.Abs(dbPath)
if err != nil {
log.Fatal("Failed to get absolute path:", err.Error())
}
wd, err := os.Getwd()
if err != nil {
log.Fatal("Failed to get working directory:", err.Error())
}
log.Printf("Working directory: %s, basename: %s\n", wd, filepath.Base(wd))
if filepath.Base(wd) == "cmd" {
dbPath = filepath.Join(filepath.Dir(wd), "verification_platform.db")
absPath = dbPath
if err == nil && filepath.Base(wd) == "cmd" {
absPath = filepath.Join(filepath.Dir(wd), "verification_platform.db")
}
DB, err = gorm.Open(sqlite.Open(absPath), &gorm.Config{
@@ -88,14 +155,16 @@ func initMySQL() {
log.Printf("Database file path: %s\n", absPath)
}
// 设置连接池(仅对非内存数据库有效)
if sqlDB, err := DB.DB(); err == nil {
sqlDB.SetMaxIdleConns(config.GetInt("database.max_idle_conns"))
sqlDB.SetMaxOpenConns(config.GetInt("database.max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour)
}
// 检查applications表结构
runMigrations()
}
func runMigrations() {
var columns []struct {
CID int
Name string
@@ -126,7 +195,7 @@ func initMySQL() {
}
// 自动迁移数据库表
err = DB.AutoMigrate(
if err := DB.AutoMigrate(
&model.User{},
&model.AppUser{},
&model.UserLevel{},
@@ -180,8 +249,7 @@ func initMySQL() {
&model.EmailTemplate{},
&model.VersionFile{},
&model.CloudVariableRecord{},
)
if err != nil {
); err != nil {
log.Println("Failed to migrate database:", err.Error())
}
+1 -1
View File
@@ -1,4 +1,4 @@
package admin
package admin
import (
"time"
+348
View File
@@ -0,0 +1,348 @@
package install
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"verification-platform-backend/internal/config"
"verification-platform-backend/internal/database"
"verification-platform-backend/internal/model"
"verification-platform-backend/pkg/response"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func SetupRoutes(r *gin.Engine) {
install := r.Group("/api/install")
{
install.GET("/status", handleGetInstallStatus)
install.POST("/setup", handleSetup)
install.POST("/test-db", handleTestDatabase)
}
}
type InstallStatus struct {
Installed bool `json:"installed"`
DbType string `json:"db_type"`
HasAdmin bool `json:"has_admin"`
ConfigExist bool `json:"config_exist"`
}
func handleGetInstallStatus(c *gin.Context) {
configExist := checkConfigFile()
hasAdmin := checkAdminExists()
installed := hasAdmin
var dbType string
if configExist {
dbType = config.GetString("database.type")
if dbType == "" {
if strings.Contains(config.GetString("database.host"), "") && config.GetString("database.host") != "localhost" {
dbType = "mysql"
} else {
dbType = "sqlite"
}
}
}
response.Success(c, InstallStatus{
Installed: installed,
DbType: dbType,
HasAdmin: hasAdmin,
ConfigExist: configExist,
})
}
type TestDBRequest struct {
DbType string `json:"db_type"`
Host string `json:"host"`
Port string `json:"port"`
Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"`
}
func handleTestDatabase(c *gin.Context) {
var req TestDBRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
if req.DbType == "sqlite" {
dbPath := "verification_platform.db"
absPath, _ := filepath.Abs(dbPath)
testDB, err := gorm.Open(sqlite.Open(absPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
response.Error(c, 400, "无法创建SQLite数据库: "+err.Error())
return
}
sqlDB, _ := testDB.DB()
sqlDB.Close()
response.Success(c, gin.H{"message": "SQLite数据库连接成功"})
return
}
if req.DbType == "mysql" {
if req.Host == "" || req.Port == "" || req.Name == "" || req.Username == "" {
response.Error(c, 400, "MySQL配置不完整")
return
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
req.Username, req.Password, req.Host, req.Port, req.Name)
testDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
response.Error(c, 400, "MySQL连接失败: "+err.Error())
return
}
sqlDB, _ := testDB.DB()
sqlDB.Close()
response.Success(c, gin.H{"message": "MySQL连接成功"})
return
}
response.Error(c, 400, "不支持的数据库类型")
}
type SetupRequest struct {
DbType string `json:"db_type"`
DbHost string `json:"db_host"`
DbPort string `json:"db_port"`
DbName string `json:"db_name"`
DbUsername string `json:"db_username"`
DbPassword string `json:"db_password"`
JwtSecret string `json:"jwt_secret"`
AdminUser string `json:"admin_user"`
AdminPass string `json:"admin_pass"`
AdminEmail string `json:"admin_email"`
UseRedis bool `json:"use_redis"`
RedisHost string `json:"redis_host"`
RedisPort string `json:"redis_port"`
RedisPass string `json:"redis_password"`
RedisDb int `json:"redis_db"`
}
func handleSetup(c *gin.Context) {
if checkAdminExists() {
response.Error(c, 400, "系统已安装")
return
}
var req SetupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
if req.AdminUser == "" || req.AdminPass == "" {
response.Error(c, 400, "管理员账号和密码不能为空")
return
}
if len(req.AdminPass) < 6 {
response.Error(c, 400, "密码长度至少6位")
return
}
jwtSecret := req.JwtSecret
if jwtSecret == "" {
jwtSecret = generateRandomSecret(32)
}
var newDB *gorm.DB
var err error
if req.DbType == "sqlite" {
dbPath := "verification_platform.db"
absPath, _ := filepath.Abs(dbPath)
newDB, err = gorm.Open(sqlite.Open(absPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
response.Error(c, 500, "SQLite数据库创建失败: "+err.Error())
return
}
} else if req.DbType == "mysql" {
if req.DbHost == "" || req.DbPort == "" || req.DbName == "" || req.DbUsername == "" {
response.Error(c, 400, "MySQL配置不完整")
return
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
req.DbUsername, req.DbPassword, req.DbHost, req.DbPort, req.DbName)
newDB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
response.Error(c, 500, "MySQL连接失败: "+err.Error())
return
}
} else {
response.Error(c, 400, "不支持的数据库类型")
return
}
if sqlDB, err := newDB.DB(); err == nil {
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
}
if err := runMigrations(newDB); err != nil {
response.Error(c, 500, "数据库迁移失败: "+err.Error())
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPass), bcrypt.DefaultCost)
if err != nil {
response.Error(c, 500, "密码加密失败")
return
}
admin := model.User{
Username: req.AdminUser,
Password: string(hashedPassword),
Email: &req.AdminEmail,
Role: "admin",
Status: "active",
}
if err := newDB.Create(&admin).Error; err != nil {
response.Error(c, 500, "创建管理员失败: "+err.Error())
return
}
configContent := generateConfigFile(req.DbType, req, jwtSecret)
if err := saveConfigFile(configContent); err != nil {
log.Printf("Warning: failed to save config file: %v", err)
}
database.DB = newDB
response.Success(c, gin.H{
"message": "安装成功",
"jwt_secret": jwtSecret,
})
}
func checkConfigFile() bool {
paths := []string{"config.yaml", "./config/config.yaml"}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
func checkAdminExists() bool {
if database.DB == nil {
return false
}
var count int64
database.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&count)
return count > 0
}
func generateRandomSecret(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return hex.EncodeToString(bytes)[:length]
}
func runMigrations(db *gorm.DB) error {
return db.AutoMigrate(
&model.User{},
&model.AppUser{},
&model.Application{},
&model.CardType{},
&model.Card{},
&model.RechargeRecord{},
&model.UserDevice{},
&model.DeviceSession{},
&model.Setting{},
&model.ExtensionAPIKey{},
&model.AgentApplication{},
&model.Package{},
&model.Order{},
)
}
func generateConfigFile(dbType string, req SetupRequest, jwtSecret string) string {
var content strings.Builder
content.WriteString("# 应用配置\n")
content.WriteString("app:\n")
content.WriteString(" name: verification-platform\n")
content.WriteString(" env: production\n")
content.WriteString(" port: \"8080\"\n")
content.WriteString(fmt.Sprintf(" jwt_secret: %s\n", jwtSecret))
content.WriteString(" jwt_expire: 24h\n\n")
content.WriteString("# 数据库配置\n")
content.WriteString("database:\n")
content.WriteString(fmt.Sprintf(" type: %s\n", dbType))
if dbType == "mysql" {
content.WriteString(fmt.Sprintf(" host: %s\n", req.DbHost))
content.WriteString(fmt.Sprintf(" port: %s\n", req.DbPort))
content.WriteString(fmt.Sprintf(" name: %s\n", req.DbName))
content.WriteString(fmt.Sprintf(" username: %s\n", req.DbUsername))
content.WriteString(fmt.Sprintf(" password: %s\n", req.DbPassword))
} else {
content.WriteString(" host: localhost\n")
content.WriteString(" port: \"3306\"\n")
content.WriteString(" name: verification_platform\n")
content.WriteString(" username: root\n")
content.WriteString(" password: \"\"\n")
}
content.WriteString(" charset: utf8mb4\n")
content.WriteString(" max_idle_conns: 10\n")
content.WriteString(" max_open_conns: 100\n\n")
content.WriteString("# Redis配置\n")
content.WriteString("redis:\n")
if req.UseRedis {
content.WriteString(fmt.Sprintf(" host: %s\n", req.RedisHost))
content.WriteString(fmt.Sprintf(" port: %s\n", req.RedisPort))
content.WriteString(fmt.Sprintf(" password: %s\n", req.RedisPass))
content.WriteString(fmt.Sprintf(" db: %d\n", req.RedisDb))
} else {
content.WriteString(" host: localhost\n")
content.WriteString(" port: \"6379\"\n")
content.WriteString(" password: \"\"\n")
content.WriteString(" db: 0\n")
}
content.WriteString("\n# 日志配置\n")
content.WriteString("log:\n")
content.WriteString(" level: info\n")
content.WriteString(" format: json\n")
return content.String()
}
func saveConfigFile(content string) error {
file, err := os.Create("config.yaml")
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(content)
return err
}
+3
View File
@@ -9,6 +9,7 @@ import (
"verification-platform-backend/internal/router/admin"
"verification-platform-backend/internal/router/extension"
"verification-platform-backend/internal/router/frontend"
"verification-platform-backend/internal/router/install"
"verification-platform-backend/pkg/response"
"github.com/gin-gonic/gin"
@@ -25,6 +26,8 @@ func SetupRoutes(r *gin.Engine) {
})
})
install.SetupRoutes(r)
frontend.SetupRoutes(r)
api := r.Group("/api/v1")
+395
View File
@@ -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>
+29 -1
View File
@@ -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')
+13 -3
View File
@@ -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/'