feat: setup wizard page for first-deploy initialization
- SetupWizardPage: step-by-step config (DB, admin, project) - Connects directly to AbstractWizard via SSH tunnel (127.0.0.1) - App.tsx: detect backend health, show wizard if not ready - Auto-switch wizard to readonly after setup - Add VITE_WIZARD_PORT build arg - Add vite-env.d.ts for type safety
This commit is contained in:
@@ -5,7 +5,9 @@ COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_API_BASE=/api
|
||||
ARG VITE_WIZARD_PORT=18080
|
||||
ENV VITE_API_BASE=$VITE_API_BASE
|
||||
ENV VITE_WIZARD_PORT=$VITE_WIZARD_PORT
|
||||
RUN npm run build
|
||||
|
||||
# Production stage — lightweight static server, no nginx
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import SetupWizardPage from '@/pages/SetupWizardPage'
|
||||
import DashboardPage from '@/pages/DashboardPage'
|
||||
import IssuesPage from '@/pages/IssuesPage'
|
||||
import IssueDetailPage from '@/pages/IssueDetailPage'
|
||||
@@ -11,12 +13,41 @@ import ProjectDetailPage from '@/pages/ProjectDetailPage'
|
||||
import MilestonesPage from '@/pages/MilestonesPage'
|
||||
import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
|
||||
import NotificationsPage from '@/pages/NotificationsPage'
|
||||
import api from '@/services/api'
|
||||
|
||||
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
||||
|
||||
type AppState = 'checking' | 'setup' | 'ready'
|
||||
|
||||
export default function App() {
|
||||
const [appState, setAppState] = useState<AppState>('checking')
|
||||
const { user, loading, login, logout } = useAuth()
|
||||
|
||||
const checkBackend = async () => {
|
||||
try {
|
||||
await api.get('/health')
|
||||
setAppState('ready')
|
||||
} catch {
|
||||
setAppState('setup')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkBackend() }, [])
|
||||
|
||||
// Checking backend availability
|
||||
if (appState === 'checking') {
|
||||
return <div className="loading">检查后端状态...</div>
|
||||
}
|
||||
|
||||
// Backend not ready — show setup wizard
|
||||
if (appState === 'setup') {
|
||||
return <SetupWizardPage wizardPort={WIZARD_PORT} onComplete={checkBackend} />
|
||||
}
|
||||
|
||||
// Backend ready but auth loading
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
|
||||
// Not logged in
|
||||
if (!user) {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -160,3 +160,26 @@ dd { font-size: .9rem; }
|
||||
|
||||
/* Text dim helper */
|
||||
.text-dim { color: var(--text-dim); font-size: .85rem; }
|
||||
|
||||
/* Setup Wizard */
|
||||
.setup-wizard { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
|
||||
.setup-container { background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 40px; max-width: 600px; width: 100%; }
|
||||
.setup-header { text-align: center; margin-bottom: 32px; }
|
||||
.setup-header h1 { font-size: 1.5rem; margin-bottom: 20px; }
|
||||
.setup-steps { display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; }
|
||||
.setup-step { font-size: .8rem; color: var(--text-dim); padding: 4px 10px; border-radius: 12px; border: 1px solid var(--border); }
|
||||
.setup-step.active { color: var(--accent); border-color: var(--accent); background: rgba(59,130,246,.1); }
|
||||
.setup-step.done { color: var(--success); border-color: var(--success); }
|
||||
.setup-step-content { animation: fadeIn .2s ease; }
|
||||
.setup-step-content h2 { margin-bottom: 8px; font-size: 1.2rem; }
|
||||
.setup-form { display: flex; flex-direction: column; gap: 12px; margin: 20px 0; }
|
||||
.setup-form label { display: flex; flex-direction: column; gap: 4px; font-size: .85rem; color: var(--text-dim); }
|
||||
.setup-form input { padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: .95rem; }
|
||||
.setup-nav { display: flex; justify-content: space-between; margin-top: 24px; }
|
||||
.setup-error { background: rgba(239,68,68,.1); border: 1px solid var(--danger); color: var(--danger); padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: .9rem; white-space: pre-line; }
|
||||
.setup-info { background: rgba(59,130,246,.08); border: 1px solid rgba(59,130,246,.2); padding: 16px; border-radius: 8px; margin: 16px 0; }
|
||||
.setup-info code { display: block; background: var(--bg); padding: 8px 12px; border-radius: 4px; margin-top: 8px; font-size: .85rem; color: var(--accent); word-break: break-all; }
|
||||
.setup-hint { color: var(--warning); font-size: .85rem; margin-top: 8px; }
|
||||
.setup-done { text-align: center; }
|
||||
.setup-done h2 { color: var(--success); margin-bottom: 12px; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
222
src/pages/SetupWizardPage.tsx
Normal file
222
src/pages/SetupWizardPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
interface Props {
|
||||
wizardPort: number
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
interface SetupForm {
|
||||
// Admin
|
||||
admin_username: string
|
||||
admin_password: string
|
||||
admin_email: string
|
||||
admin_full_name: string
|
||||
// Database
|
||||
db_host: string
|
||||
db_port: number
|
||||
db_user: string
|
||||
db_password: string
|
||||
db_database: string
|
||||
// Backend
|
||||
backend_url: string
|
||||
// Default project
|
||||
project_name: string
|
||||
project_description: string
|
||||
}
|
||||
|
||||
const STEPS = ['欢迎', '数据库', '管理员', '项目', '完成']
|
||||
|
||||
export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [wizardOk, setWizardOk] = useState<boolean | null>(null)
|
||||
const [form, setForm] = useState<SetupForm>({
|
||||
admin_username: 'admin',
|
||||
admin_password: '',
|
||||
admin_email: '',
|
||||
admin_full_name: 'Admin',
|
||||
db_host: 'mysql',
|
||||
db_port: 3306,
|
||||
db_user: 'harborforge',
|
||||
db_password: 'harborforge_pass',
|
||||
db_database: 'harborforge',
|
||||
backend_url: '',
|
||||
project_name: 'Default',
|
||||
project_description: '默认项目',
|
||||
})
|
||||
|
||||
const wizardApi = axios.create({
|
||||
baseURL: `http://127.0.0.1:${wizardPort}`,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
const set = (key: keyof SetupForm, value: string | number) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const checkWizard = async () => {
|
||||
setError('')
|
||||
try {
|
||||
await wizardApi.get('/health')
|
||||
setWizardOk(true)
|
||||
setStep(1)
|
||||
} catch {
|
||||
setWizardOk(false)
|
||||
setError(`无法连接 AbstractWizard (127.0.0.1:${wizardPort})。请确认已通过 SSH 隧道映射端口:\nssh -L ${wizardPort}:127.0.0.1:${wizardPort} user@server`)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
setError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
const config = {
|
||||
admin: {
|
||||
username: form.admin_username,
|
||||
password: form.admin_password,
|
||||
email: form.admin_email,
|
||||
full_name: form.admin_full_name,
|
||||
},
|
||||
database: {
|
||||
host: form.db_host,
|
||||
port: form.db_port,
|
||||
user: form.db_user,
|
||||
password: form.db_password,
|
||||
database: form.db_database,
|
||||
},
|
||||
backend_url: form.backend_url || undefined,
|
||||
default_project: form.project_name
|
||||
? { name: form.project_name, description: form.project_description }
|
||||
: undefined,
|
||||
}
|
||||
|
||||
await wizardApi.put('/api/v1/config/harborforge.json', config, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// Switch wizard to readonly after setup
|
||||
await wizardApi.put('/api/v1/mode', { mode: 'readonly' }).catch(() => {})
|
||||
|
||||
setStep(4)
|
||||
} catch (err: any) {
|
||||
setError(`保存配置失败: ${err.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setup-wizard">
|
||||
<div className="setup-container">
|
||||
<div className="setup-header">
|
||||
<h1>⚓ HarborForge 初始化向导</h1>
|
||||
<div className="setup-steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<span key={i} className={`setup-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
|
||||
{i < step ? '✓' : i + 1}. {s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="setup-error">{error}</div>}
|
||||
|
||||
{/* Step 0: Welcome */}
|
||||
{step === 0 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>欢迎使用 HarborForge</h2>
|
||||
<p>Agent/人类协同任务管理平台</p>
|
||||
<div className="setup-info">
|
||||
<p>⚠️ 初始化向导通过 SSH 隧道连接 AbstractWizard,请确保已映射端口:</p>
|
||||
<code>ssh -L {wizardPort}:127.0.0.1:{wizardPort} user@your-server</code>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={checkWizard}>
|
||||
连接 Wizard 开始配置
|
||||
</button>
|
||||
{wizardOk === false && (
|
||||
<p className="setup-hint">连接失败,请检查 SSH 隧道是否正确建立。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Database */}
|
||||
{step === 1 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>数据库配置</h2>
|
||||
<p className="text-dim">配置 MySQL 连接信息(使用 docker-compose 内置 MySQL 可保持默认值)</p>
|
||||
<div className="setup-form">
|
||||
<label>主机 <input value={form.db_host} onChange={(e) => set('db_host', e.target.value)} /></label>
|
||||
<label>端口 <input type="number" value={form.db_port} onChange={(e) => set('db_port', Number(e.target.value))} /></label>
|
||||
<label>用户名 <input value={form.db_user} onChange={(e) => set('db_user', e.target.value)} /></label>
|
||||
<label>密码 <input type="password" value={form.db_password} onChange={(e) => set('db_password', e.target.value)} /></label>
|
||||
<label>数据库名 <input value={form.db_database} onChange={(e) => set('db_database', e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="setup-nav">
|
||||
<button className="btn-back" onClick={() => setStep(0)}>上一步</button>
|
||||
<button className="btn-primary" onClick={() => setStep(2)}>下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Admin */}
|
||||
{step === 2 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>管理员账号</h2>
|
||||
<p className="text-dim">创建首个管理员用户</p>
|
||||
<div className="setup-form">
|
||||
<label>用户名 <input value={form.admin_username} onChange={(e) => set('admin_username', e.target.value)} required /></label>
|
||||
<label>密码 <input type="password" value={form.admin_password} onChange={(e) => set('admin_password', e.target.value)} required placeholder="设置管理员密码" /></label>
|
||||
<label>邮箱 <input type="email" value={form.admin_email} onChange={(e) => set('admin_email', e.target.value)} placeholder="admin@example.com" /></label>
|
||||
<label>姓名 <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="setup-nav">
|
||||
<button className="btn-back" onClick={() => setStep(1)}>上一步</button>
|
||||
<button className="btn-primary" onClick={() => {
|
||||
if (!form.admin_password) { setError('请设置管理员密码'); return }
|
||||
setError('')
|
||||
setStep(3)
|
||||
}}>下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Project */}
|
||||
{step === 3 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>默认项目(可选)</h2>
|
||||
<p className="text-dim">创建一个初始项目,也可以跳过</p>
|
||||
<div className="setup-form">
|
||||
<label>项目名称 <input value={form.project_name} onChange={(e) => set('project_name', e.target.value)} placeholder="留空则跳过" /></label>
|
||||
<label>项目描述 <input value={form.project_description} onChange={(e) => set('project_description', e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="setup-nav">
|
||||
<button className="btn-back" onClick={() => setStep(2)}>上一步</button>
|
||||
<button className="btn-primary" onClick={saveConfig} disabled={saving}>
|
||||
{saving ? '保存中...' : '完成配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Done */}
|
||||
{step === 4 && (
|
||||
<div className="setup-step-content">
|
||||
<div className="setup-done">
|
||||
<h2>✅ 初始化完成!</h2>
|
||||
<p>配置已保存到 AbstractWizard,Wizard 已切换为只读模式。</p>
|
||||
<p>后端将在检测到配置后自动启动。</p>
|
||||
<div className="setup-info">
|
||||
<p>请等待后端启动完成,然后刷新页面进入登录界面。</p>
|
||||
<p>管理员账号: <strong>{form.admin_username}</strong></p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={onComplete}>
|
||||
刷新检查
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/vite-env.d.ts
vendored
Normal file
10
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
readonly VITE_WIZARD_PORT: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user