- 新增安装控制器 (install_controller.go) - 新增安装服务 (install_service.go) - 添加 Redis 配置结构到配置服务 - 更新路由注册,添加安装 API - 创建前端安装页面 (Install.tsx) - 支持 SQLite 和 MySQL 数据库选择 - Redis 为可选配置 - 安装完成后创建管理员账号 API 端点: - GET /api/v1/install/status - 获取安装状态 - POST /api/v1/install - 执行安装
This commit is contained in:
+29
-9
@@ -1,6 +1,5 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Frontend - React Web UI
|
||||
frontend:
|
||||
image: git.viaeon.com/admin/taskpool-react:latest
|
||||
ports:
|
||||
@@ -9,13 +8,34 @@ services:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# Backend - Go API Server
|
||||
backend:
|
||||
image: git.viaeon.com/admin/taskpool:latest
|
||||
environment:
|
||||
- DB_TYPE=sqlite
|
||||
- DB_PATH=/data/taskpool.db
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
- "8052:8052"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./configs:/app/configs
|
||||
- ./envs:/app/envs
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BH_SERVER_PORT=8052
|
||||
- BH_SERVER_HOST=0.0.0.0
|
||||
- BH_DB_TYPE=sqlite
|
||||
- BH_DB_PATH=/app/data/taskpool.db
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis (可选 - 用于缓存和队列)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- redis # 使用 --profile redis 启用
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
@@ -0,0 +1,110 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/engigu/baihu-panel/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type InstallController struct {
|
||||
installService *services.InstallService
|
||||
}
|
||||
|
||||
func NewInstallController() *InstallController {
|
||||
return &InstallController{
|
||||
installService: services.NewInstallService(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetInstallStatus 获取安装状态
|
||||
// @Summary 获取安装状态
|
||||
// @Description 检查系统是否已完成安装
|
||||
// @Tags 安装
|
||||
// @Produce json
|
||||
// @Success 200 {object} services.InstallStatus
|
||||
// @Router /api/v1/install/status [get]
|
||||
func (c *InstallController) GetInstallStatus(ctx *gin.Context) {
|
||||
status, err := c.installService.CheckInstallStatus()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// Install 执行安装
|
||||
// @Summary 执行安装
|
||||
// @Description 初始化系统配置和管理员账号
|
||||
// @Tags 安装
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body services.InstallRequest true "安装请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/install [post]
|
||||
func (c *InstallController) Install(ctx *gin.Context) {
|
||||
// 先检查是否已安装
|
||||
status, err := c.installService.CheckInstallStatus()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "检查安装状态失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if status.Installed {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "系统已安装,无法重复安装"})
|
||||
return
|
||||
}
|
||||
|
||||
var req services.InstallRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.AdminUsername == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "管理员用户名不能为空"})
|
||||
return
|
||||
}
|
||||
if req.AdminPassword == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "管理员密码不能为空"})
|
||||
return
|
||||
}
|
||||
if len(req.AdminPassword) < 6 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "管理员密码至少6位"})
|
||||
return
|
||||
}
|
||||
|
||||
// MySQL 必填验证
|
||||
if req.DBType == "mysql" {
|
||||
if req.DBHost == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "MySQL 主机不能为空"})
|
||||
return
|
||||
}
|
||||
if req.DBName == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "MySQL 数据库名不能为空"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 启用时的验证
|
||||
if req.RedisEnabled {
|
||||
if req.RedisHost == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Redis 主机不能为空"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 执行安装
|
||||
if err := c.installService.Install(&req); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "安装失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"message": "安装成功",
|
||||
"admin_username": req.AdminUsername,
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,13 @@ func initPublicAPIRoutes(api *gin.RouterGroup, c *Controllers) {
|
||||
ctx.JSON(200, gin.H{"message": "pong"})
|
||||
})
|
||||
|
||||
// Install routes (无需认证,仅在未安装时可用)
|
||||
install := api.Group("/install")
|
||||
{
|
||||
install.GET("/status", c.Install.GetInstallStatus)
|
||||
install.POST("", c.Install.Install)
|
||||
}
|
||||
|
||||
// api.GET("/debug/goroutines", func(ctx *gin.Context) {
|
||||
// buf := make([]byte, 1024*1024)
|
||||
// n := runtime.Stack(buf, true)
|
||||
|
||||
@@ -71,6 +71,7 @@ func RegisterControllers() *Controllers {
|
||||
Monitor: controllers.NewMonitorController(executorService),
|
||||
Interconnect: controllers.NewInterconnectController(interconnectService),
|
||||
Data: controllers.NewDataController(taskController, envController),
|
||||
Install: controllers.NewInstallController(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ type Controllers struct {
|
||||
Monitor *controllers.MonitorController
|
||||
Interconnect *controllers.InterconnectController
|
||||
Data *controllers.DataController
|
||||
Install *controllers.InstallController
|
||||
}
|
||||
|
||||
func Setup(c *Controllers) *gin.Engine {
|
||||
|
||||
@@ -36,10 +36,19 @@ type SecurityConfig struct {
|
||||
Secret string `ini:"secret"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Enabled bool `ini:"enabled"`
|
||||
Host string `ini:"host"`
|
||||
Port int `ini:"port"`
|
||||
Password string `ini:"password"`
|
||||
DB int `ini:"db"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Server ServerConfig `ini:"server"`
|
||||
Database DatabaseConfig `ini:"database"`
|
||||
Security SecurityConfig `ini:"security"`
|
||||
Redis RedisConfig `ini:"redis"`
|
||||
}
|
||||
|
||||
var Config *AppConfig
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/engigu/baihu-panel/internal/constant"
|
||||
"github.com/engigu/baihu-panel/internal/database"
|
||||
"github.com/engigu/baihu-panel/internal/logger"
|
||||
"github.com/engigu/baihu-panel/internal/utils"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// InstallRequest 安装请求
|
||||
type InstallRequest struct {
|
||||
// 数据库配置
|
||||
DBType string `json:"db_type" binding:"required"` // sqlite 或 mysql
|
||||
DBHost string `json:"db_host"` // MySQL 主机
|
||||
DBPort int `json:"db_port"` // MySQL 端口
|
||||
DBUser string `json:"db_user"` // MySQL 用户名
|
||||
DBPassword string `json:"db_password"` // MySQL 密码
|
||||
DBName string `json:"db_name"` // MySQL 数据库名
|
||||
DBPath string `json:"db_path"` // SQLite 数据库路径
|
||||
DBSSLMode string `json:"db_ssl_mode"` // SSL 模式
|
||||
|
||||
// Redis 配置(可选)
|
||||
RedisEnabled bool `json:"redis_enabled"` // 是否启用 Redis
|
||||
RedisHost string `json:"redis_host"` // Redis 主机
|
||||
RedisPort int `json:"redis_port"` // Redis 端口
|
||||
RedisPassword string `json:"redis_password"` // Redis 密码
|
||||
RedisDB int `json:"redis_db"` // Redis 数据库索引
|
||||
|
||||
// 管理员账号
|
||||
AdminUsername string `json:"admin_username" binding:"required"` // 管理员用户名
|
||||
AdminPassword string `json:"admin_password" binding:"required"` // 管理员密码
|
||||
AdminEmail string `json:"admin_email"` // 管理员邮箱
|
||||
|
||||
// 站点设置
|
||||
SiteTitle string `json:"site_title"` // 站点标题
|
||||
SiteSubtitle string `json:"site_subtitle"` // 站点副标题
|
||||
}
|
||||
|
||||
// InstallStatus 安装状态
|
||||
type InstallStatus struct {
|
||||
Installed bool `json:"installed"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
DBType string `json:"db_type"`
|
||||
}
|
||||
|
||||
// InstallService 安装服务
|
||||
type InstallService struct {
|
||||
settingsService *SettingsService
|
||||
userService *UserService
|
||||
}
|
||||
|
||||
// NewInstallService 创建安装服务
|
||||
func NewInstallService() *InstallService {
|
||||
return &InstallService{
|
||||
settingsService: NewSettingsService(),
|
||||
userService: NewUserService(),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckInstallStatus 检查安装状态
|
||||
func (s *InstallService) CheckInstallStatus() (*InstallStatus, error) {
|
||||
status := &InstallStatus{
|
||||
ConfigPath: constant.ConfigPath,
|
||||
}
|
||||
|
||||
// 检查配置文件是否存在
|
||||
configExists := false
|
||||
if _, err := os.Stat(constant.ConfigPath); err == nil {
|
||||
configExists = true
|
||||
}
|
||||
|
||||
// 检查是否已初始化(数据库中有管理员用户)
|
||||
initialized := false
|
||||
if configExists {
|
||||
// 尝试检查是否有管理员用户
|
||||
admin := s.userService.GetUserByUsername("admin")
|
||||
if admin != nil {
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
// 同时检查数据库中的初始化标志
|
||||
dbInitialized := s.settingsService.GetSettingValue(constant.SectionSystem, constant.KeyInitialized)
|
||||
if dbInitialized == "true" {
|
||||
initialized = true
|
||||
}
|
||||
|
||||
status.Installed = initialized
|
||||
status.DBType = Config.Database.Type
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Install 执行安装
|
||||
func (s *InstallService) Install(req *InstallRequest) error {
|
||||
// 1. 创建配置文件
|
||||
if err := s.createConfigFile(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 重新加载数据库配置
|
||||
if err := s.reloadDatabase(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 初始化数据库
|
||||
if err := database.Migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. 初始化设置
|
||||
if err := s.settingsService.InitSettings(); err != nil {
|
||||
logger.Warnf("[Install] 初始化设置失败: %v", err)
|
||||
}
|
||||
|
||||
// 5. 创建管理员账号
|
||||
if err := s.createAdmin(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. 保存站点设置
|
||||
if req.SiteTitle != "" || req.SiteSubtitle != "" {
|
||||
siteSettings := make(map[string]string)
|
||||
if req.SiteTitle != "" {
|
||||
siteSettings[constant.KeyTitle] = req.SiteTitle
|
||||
}
|
||||
if req.SiteSubtitle != "" {
|
||||
siteSettings[constant.KeySubtitle] = req.SiteSubtitle
|
||||
}
|
||||
if err := s.settingsService.UpdateSectionSettings(constant.SectionSite, siteSettings); err != nil {
|
||||
logger.Warnf("[Install] 保存站点设置失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 标记已初始化
|
||||
if err := s.settingsService.SaveSetting(constant.SectionSystem, constant.KeyInitialized, "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("[Install] 安装完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createConfigFile 创建配置文件
|
||||
func (s *InstallService) createConfigFile(req *InstallRequest) error {
|
||||
// 确保配置目录存在
|
||||
configDir := filepath.Dir(constant.ConfigPath)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建配置文件
|
||||
cfg := ini.Empty()
|
||||
|
||||
// [server] 配置
|
||||
serverSection, _ := cfg.NewSection("server")
|
||||
serverSection.NewKey("port", "8052")
|
||||
serverSection.NewKey("host", "0.0.0.0")
|
||||
serverSection.NewKey("cookie_name", "BHToken")
|
||||
|
||||
// [database] 配置
|
||||
dbSection, _ := cfg.NewSection("database")
|
||||
dbSection.NewKey("type", req.DBType)
|
||||
|
||||
if req.DBType == "sqlite" {
|
||||
dbPath := req.DBPath
|
||||
if dbPath == "" {
|
||||
dbPath = constant.DefaultDBPath
|
||||
}
|
||||
dbSection.NewKey("path", dbPath)
|
||||
} else if req.DBType == "mysql" {
|
||||
dbSection.NewKey("host", req.DBHost)
|
||||
dbSection.NewKey("port", intToStr(req.DBPort))
|
||||
dbSection.NewKey("user", req.DBUser)
|
||||
dbSection.NewKey("password", req.DBPassword)
|
||||
dbSection.NewKey("dbname", req.DBName)
|
||||
if req.DBSSLMode != "" {
|
||||
dbSection.NewKey("ssl_mode", req.DBSSLMode)
|
||||
}
|
||||
}
|
||||
dbSection.NewKey("table_prefix", "baihu_")
|
||||
|
||||
// [redis] 配置(如果启用)
|
||||
if req.RedisEnabled {
|
||||
redisSection, _ := cfg.NewSection("redis")
|
||||
redisSection.NewKey("enabled", "true")
|
||||
redisSection.NewKey("host", req.RedisHost)
|
||||
redisSection.NewKey("port", intToStr(req.RedisPort))
|
||||
if req.RedisPassword != "" {
|
||||
redisSection.NewKey("password", req.RedisPassword)
|
||||
}
|
||||
redisSection.NewKey("db", intToStr(req.RedisDB))
|
||||
}
|
||||
|
||||
// [security] 配置
|
||||
securitySection, _ := cfg.NewSection("security")
|
||||
secret := utils.RandomString(32)
|
||||
securitySection.NewKey("secret", secret)
|
||||
|
||||
// 保存配置文件
|
||||
if err := cfg.SaveTo(constant.ConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof("[Install] 配置文件已保存到: %s", constant.ConfigPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadDatabase 重新加载数据库
|
||||
func (s *InstallService) reloadDatabase(req *InstallRequest) error {
|
||||
dbCfg := &database.Config{
|
||||
Type: req.DBType,
|
||||
Host: req.DBHost,
|
||||
Port: req.DBPort,
|
||||
User: req.DBUser,
|
||||
Password: req.DBPassword,
|
||||
DBName: req.DBName,
|
||||
Path: req.DBPath,
|
||||
SSLMode: req.DBSSLMode,
|
||||
}
|
||||
|
||||
if req.DBType == "sqlite" && req.DBPath == "" {
|
||||
dbCfg.Path = constant.DefaultDBPath
|
||||
}
|
||||
|
||||
if err := database.Init(dbCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAdmin 创建管理员账号
|
||||
func (s *InstallService) createAdmin(req *InstallRequest) error {
|
||||
// 检查用户是否已存在
|
||||
existingUser := s.userService.GetUserByUsername(req.AdminUsername)
|
||||
if existingUser != nil {
|
||||
logger.Info("[Install] 管理员账号已存在,跳过创建")
|
||||
return nil
|
||||
}
|
||||
|
||||
email := req.AdminEmail
|
||||
if email == "" {
|
||||
email = "admin@local"
|
||||
}
|
||||
|
||||
s.userService.CreateUser(req.AdminUsername, req.AdminPassword, email, "admin")
|
||||
logger.Infof("[Install] 管理员账号创建成功: %s", req.AdminUsername)
|
||||
return nil
|
||||
}
|
||||
|
||||
// intToStr 整数转字符串
|
||||
func intToStr(n int) string {
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
return utils.IntToStr(n)
|
||||
}
|
||||
@@ -60,3 +60,33 @@ func TrimLastRunes(s string, maxRunes int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IntToStr 将整数转换为字符串
|
||||
func IntToStr(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
|
||||
var negative bool
|
||||
if n < 0 {
|
||||
negative = true
|
||||
n = -n
|
||||
}
|
||||
|
||||
var digits []byte
|
||||
for n > 0 {
|
||||
digits = append(digits, byte('0'+n%10))
|
||||
n /= 10
|
||||
}
|
||||
|
||||
if negative {
|
||||
digits = append(digits, '-')
|
||||
}
|
||||
|
||||
// Reverse
|
||||
for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 {
|
||||
digits[i], digits[j] = digits[j], digits[i]
|
||||
}
|
||||
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
@@ -218,4 +218,19 @@ export function useUpdateSettings() {
|
||||
mutationFn: api.updateSettings,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
})
|
||||
}
|
||||
|
||||
// Install hooks
|
||||
export function useInstallStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['installStatus'],
|
||||
queryFn: api.getInstallStatus,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstall() {
|
||||
return useMutation({
|
||||
mutationFn: api.install,
|
||||
})
|
||||
}
|
||||
@@ -171,4 +171,33 @@ export const updateSettings = (data: any) => request<any>('/settings', {
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
// Install
|
||||
export const getInstallStatus = () => request<{ installed: boolean; config_path: string; db_type: string }>('/install/status')
|
||||
|
||||
export const install = async (data: {
|
||||
db_type: string
|
||||
db_host?: string
|
||||
db_port?: number
|
||||
db_user?: string
|
||||
db_password?: string
|
||||
db_name?: string
|
||||
db_path?: string
|
||||
db_ssl_mode?: string
|
||||
redis_enabled?: boolean
|
||||
redis_host?: string
|
||||
redis_port?: number
|
||||
redis_password?: string
|
||||
redis_db?: number
|
||||
admin_username: string
|
||||
admin_password: string
|
||||
admin_email?: string
|
||||
site_title?: string
|
||||
site_subtitle?: string
|
||||
}) => {
|
||||
return request<{ message: string; admin_username: string }>('/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export { request }
|
||||
@@ -0,0 +1,632 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useInstallStatus, useInstall } from '@/api/hooks'
|
||||
import { Database, Server, User, Lock, AlertCircle, CheckCircle, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
export default function Install() {
|
||||
const navigate = useNavigate()
|
||||
const { data: statusData, isLoading: statusLoading } = useInstallStatus()
|
||||
const installMutation = useInstall()
|
||||
|
||||
const [step, setStep] = useState(1) // 1: Database, 2: Redis, 3: Admin
|
||||
const [dbType, setDbType] = useState<'sqlite' | 'mysql'>('sqlite')
|
||||
const [showRedis, setShowRedis] = useState(false)
|
||||
|
||||
// Database config
|
||||
const [dbHost, setDbHost] = useState('localhost')
|
||||
const [dbPort, setDbPort] = useState('3306')
|
||||
const [dbUser, setDbUser] = useState('root')
|
||||
const [dbPassword, setDbPassword] = useState('')
|
||||
const [dbName, setDbName] = useState('taskpool')
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
|
||||
// Redis config
|
||||
const [redisEnabled, setRedisEnabled] = useState(false)
|
||||
const [redisHost, setRedisHost] = useState('localhost')
|
||||
const [redisPort, setRedisPort] = useState('6379')
|
||||
const [redisPassword, setRedisPassword] = useState('')
|
||||
const [redisDB, setRedisDB] = useState('0')
|
||||
|
||||
// Admin config
|
||||
const [adminUsername, setAdminUsername] = useState('admin')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminEmail, setAdminEmail] = useState('')
|
||||
|
||||
// Site config
|
||||
const [siteTitle, setSiteTitle] = useState('TaskPool')
|
||||
const [siteSubtitle, setSiteSubtitle] = useState('自动化任务调度平台')
|
||||
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
// If already installed, redirect to login
|
||||
if (statusData?.installed && !success) {
|
||||
navigate({ to: '/login' })
|
||||
return null
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-primary)',
|
||||
}}>
|
||||
<div style={{ color: 'var(--text-muted)' }}>Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
setError('')
|
||||
|
||||
if (adminPassword.length < 6) {
|
||||
setError('密码至少需要6位')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await installMutation.mutateAsync({
|
||||
db_type: dbType,
|
||||
db_host: dbType === 'mysql' ? dbHost : undefined,
|
||||
db_port: dbType === 'mysql' ? parseInt(dbPort) : undefined,
|
||||
db_user: dbType === 'mysql' ? dbUser : undefined,
|
||||
db_password: dbType === 'mysql' ? dbPassword : undefined,
|
||||
db_name: dbType === 'mysql' ? dbName : undefined,
|
||||
db_path: dbType === 'sqlite' ? dbPath : undefined,
|
||||
redis_enabled: redisEnabled,
|
||||
redis_host: redisEnabled ? redisHost : undefined,
|
||||
redis_port: redisEnabled ? parseInt(redisPort) : undefined,
|
||||
redis_password: redisEnabled ? redisPassword : undefined,
|
||||
redis_db: redisEnabled ? parseInt(redisDB) : undefined,
|
||||
admin_username: adminUsername,
|
||||
admin_password: adminPassword,
|
||||
admin_email: adminEmail || undefined,
|
||||
site_title: siteTitle,
|
||||
site_subtitle: siteSubtitle,
|
||||
})
|
||||
setSuccess(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message || '安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
const renderStepIndicator = () => (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 32 }}>
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} style={{
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: s <= step ? 'var(--bg-accent)' : 'var(--border)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderDatabaseStep = () => (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 24 }}>数据库配置</h2>
|
||||
|
||||
{/* Database type selector */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 24 }}>
|
||||
<button
|
||||
onClick={() => setDbType('sqlite')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
background: dbType === 'sqlite' ? 'var(--bg-accent)' : 'var(--bg-primary)',
|
||||
border: `1px solid ${dbType === 'sqlite' ? 'var(--bg-accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Database size={20} strokeWidth={1.5} style={{ color: dbType === 'sqlite' ? 'white' : 'var(--text-primary)', marginBottom: 8 }} />
|
||||
<div style={{ color: dbType === 'sqlite' ? 'white' : 'var(--text-primary)', fontWeight: 500 }}>SQLite</div>
|
||||
<div style={{ color: dbType === 'sqlite' ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', fontSize: 12, marginTop: 4 }}>轻量级,无需额外服务</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDbType('mysql')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
background: dbType === 'mysql' ? 'var(--bg-accent)' : 'var(--bg-primary)',
|
||||
border: `1px solid ${dbType === 'mysql' ? 'var(--bg-accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Server size={20} strokeWidth={1.5} style={{ color: dbType === 'mysql' ? 'white' : 'var(--text-primary)', marginBottom: 8 }} />
|
||||
<div style={{ color: dbType === 'mysql' ? 'white' : 'var(--text-primary)', fontWeight: 500 }}>MySQL</div>
|
||||
<div style={{ color: dbType === 'mysql' ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', fontSize: 12, marginTop: 4 }}>高性能,适合大规模</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SQLite config */}
|
||||
{dbType === 'sqlite' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>
|
||||
数据库路径 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
placeholder="默认: data/taskpool.db"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MySQL config */}
|
||||
{dbType === 'mysql' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>主机</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dbHost}
|
||||
onChange={(e) => setDbHost(e.target.value)}
|
||||
placeholder="localhost"
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dbPort}
|
||||
onChange={(e) => setDbPort(e.target.value)}
|
||||
placeholder="3306"
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dbUser}
|
||||
onChange={(e) => setDbUser(e.target.value)}
|
||||
placeholder="root"
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={dbPassword}
|
||||
onChange={(e) => setDbPassword(e.target.value)}
|
||||
placeholder="数据库密码"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>数据库名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dbName}
|
||||
onChange={(e) => setDbName(e.target.value)}
|
||||
placeholder="taskpool"
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redis config (optional) */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<button
|
||||
onClick={() => setShowRedis(!showRedis)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{showRedis ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
<span>Redis 配置 (可选)</span>
|
||||
</button>
|
||||
|
||||
{showRedis && (
|
||||
<div style={{ marginTop: 16, padding: 16, background: 'var(--bg-primary)', borderRadius: 8 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={redisEnabled}
|
||||
onChange={(e) => setRedisEnabled(e.target.checked)}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
<span style={{ fontWeight: 500 }}>启用 Redis</span>
|
||||
</label>
|
||||
|
||||
{redisEnabled && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>主机</label>
|
||||
<input
|
||||
type="text"
|
||||
value={redisHost}
|
||||
onChange={(e) => setRedisHost(e.target.value)}
|
||||
placeholder="localhost"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={redisPort}
|
||||
onChange={(e) => setRedisPort(e.target.value)}
|
||||
placeholder="6379"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={redisPassword}
|
||||
onChange={(e) => setRedisPassword(e.target.value)}
|
||||
placeholder="Redis 密码 (可选)"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAdminStep = () => (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 24 }}>管理员账号</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>用户名</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<User size={16} strokeWidth={1.5} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={adminUsername}
|
||||
onChange={(e) => setAdminUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px 10px 40px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={16} strokeWidth={1.5} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
placeholder="至少6位密码"
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px 10px 40px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>邮箱 (可选)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 16 }}>站点设置</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>站点标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={siteTitle}
|
||||
onChange={(e) => setSiteTitle(e.target.value)}
|
||||
placeholder="TaskPool"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8, fontSize: 13 }}>站点副标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={siteSubtitle}
|
||||
onChange={(e) => setSiteSubtitle(e.target.value)}
|
||||
placeholder="自动化任务调度平台"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-primary)',
|
||||
padding: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
padding: 32,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
background: 'var(--bg-accent)',
|
||||
borderRadius: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
}}>
|
||||
<span style={{ color: 'white', fontSize: 24, fontWeight: 700 }}>T</span>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>安装向导</h1>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 14 }}>配置您的 TaskPool 实例</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<CheckCircle size={48} strokeWidth={1.5} style={{ color: '#22c55e', marginBottom: 16 }} />
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>安装成功</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 14, marginBottom: 24 }}>
|
||||
您可以使用管理员账号登录系统
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/login' })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
background: 'var(--bg-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
前往登录
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderStepIndicator()}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
background: '#ef444415',
|
||||
borderRadius: 6,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<AlertCircle size={16} style={{ color: '#ef4444' }} />
|
||||
<span style={{ color: '#ef4444', fontSize: 13 }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && renderDatabaseStep()}
|
||||
{step === 3 && renderAdminStep()}
|
||||
|
||||
{/* Navigation */}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 32 }}>
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={() => setStep(step - 1)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
background: 'var(--bg-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installMutation.isPending}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
background: 'var(--bg-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: installMutation.isPending ? 'not-allowed' : 'pointer',
|
||||
opacity: installMutation.isPending ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{installMutation.isPending ? '安装中...' : '完成安装'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import Interconnect from '@/pages/Interconnect'
|
||||
import Terminal from '@/pages/Terminal'
|
||||
import Settings from '@/pages/Settings'
|
||||
import Login from '@/pages/Login'
|
||||
import Install from '@/pages/Install'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
@@ -19,6 +20,12 @@ const loginRoute = createRoute({
|
||||
component: Login,
|
||||
})
|
||||
|
||||
const installRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/install',
|
||||
component: Install,
|
||||
})
|
||||
|
||||
const layoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
@@ -69,6 +76,7 @@ const settingsRoute = createRoute({
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
installRoute,
|
||||
layoutRoute.addChildren([
|
||||
dashboardRoute,
|
||||
tasksRoute,
|
||||
|
||||
Reference in New Issue
Block a user