feat: 添加安装页面,支持 SQLite 和 Redis 配置
Build and Deploy / build-and-push (push) Failing after 42s

- 新增安装控制器 (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:
2026-07-21 17:07:41 +00:00
parent c55f184cfa
commit f7a7d44ebe
12 changed files with 1134 additions and 9 deletions
+15
View File
@@ -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,
})
}
+29
View File
@@ -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 }
+632
View File
@@ -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
View File
@@ -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,