Merge pull request 'fix/wizard-init-flow' (#1) from fix/wizard-init-flow into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
h z
2026-03-11 10:34:54 +00:00
5 changed files with 48 additions and 35 deletions

View File

@@ -4,9 +4,7 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
COPY . . COPY . .
ARG VITE_API_BASE=/api
ARG VITE_WIZARD_PORT=18080 ARG VITE_WIZARD_PORT=18080
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_WIZARD_PORT=$VITE_WIZARD_PORT ENV VITE_WIZARD_PORT=$VITE_WIZARD_PORT
RUN npm run build RUN npm run build

View File

@@ -13,9 +13,10 @@ import ProjectDetailPage from '@/pages/ProjectDetailPage'
import MilestonesPage from '@/pages/MilestonesPage' import MilestonesPage from '@/pages/MilestonesPage'
import MilestoneDetailPage from '@/pages/MilestoneDetailPage' import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
import NotificationsPage from '@/pages/NotificationsPage' import NotificationsPage from '@/pages/NotificationsPage'
import api from '@/services/api' import axios from 'axios'
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080 const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
const WIZARD_BASE = `http://127.0.0.1:${WIZARD_PORT}`
type AppState = 'checking' | 'setup' | 'ready' type AppState = 'checking' | 'setup' | 'ready'
@@ -23,31 +24,40 @@ export default function App() {
const [appState, setAppState] = useState<AppState>('checking') const [appState, setAppState] = useState<AppState>('checking')
const { user, loading, login, logout } = useAuth() const { user, loading, login, logout } = useAuth()
const checkBackend = async () => { useEffect(() => {
checkInitialized()
}, [])
const checkInitialized = async () => {
try { try {
await api.get('/health') const res = await axios.get(`${WIZARD_BASE}/api/v1/config/harborforge.json`, {
timeout: 5000,
})
const cfg = res.data || {}
if (cfg.backend_url) {
localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url)
}
if (cfg.initialized === true) {
setAppState('ready') setAppState('ready')
} else {
setAppState('setup')
}
} catch { } catch {
// Wizard unreachable or config doesn't exist → setup needed
setAppState('setup') setAppState('setup')
} }
} }
useEffect(() => { checkBackend() }, [])
// Checking backend availability
if (appState === 'checking') { if (appState === 'checking') {
return <div className="loading">...</div> return <div className="loading">...</div>
} }
// Backend not ready — show setup wizard
if (appState === 'setup') { if (appState === 'setup') {
return <SetupWizardPage wizardPort={WIZARD_PORT} onComplete={checkBackend} /> return <SetupWizardPage wizardBase={WIZARD_BASE} onComplete={checkInitialized} />
} }
// Backend ready but auth loading
if (loading) return <div className="loading">...</div> if (loading) return <div className="loading">...</div>
// Not logged in
if (!user) { if (!user) {
return ( return (
<BrowserRouter> <BrowserRouter>

View File

@@ -28,7 +28,7 @@ export default function DashboardPage() {
<span className="stat-number">{stats.total_issues}</span> <span className="stat-number">{stats.total_issues}</span>
<span className="stat-label"> Issues</span> <span className="stat-label"> Issues</span>
</div> </div>
{Object.entries(stats.by_status).map(([k, v]) => ( {Object.entries(stats.by_status || {}).map(([k, v]) => (
<div className="stat-card" key={k} style={{ borderLeftColor: statusColors[k] || '#ccc' }}> <div className="stat-card" key={k} style={{ borderLeftColor: statusColors[k] || '#ccc' }}>
<span className="stat-number">{v}</span> <span className="stat-number">{v}</span>
<span className="stat-label">{k}</span> <span className="stat-label">{k}</span>
@@ -39,7 +39,7 @@ export default function DashboardPage() {
<div className="section"> <div className="section">
<h3></h3> <h3></h3>
<div className="bar-chart"> <div className="bar-chart">
{Object.entries(stats.by_priority).map(([k, v]) => ( {Object.entries(stats.by_priority || {}).map(([k, v]) => (
<div className="bar-row" key={k}> <div className="bar-row" key={k}>
<span className="bar-label">{k}</span> <span className="bar-label">{k}</span>
<div className="bar" style={{ <div className="bar" style={{
@@ -58,7 +58,7 @@ export default function DashboardPage() {
<tr><th>ID</th><th></th><th></th><th></th><th></th></tr> <tr><th>ID</th><th></th><th></th><th></th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{stats.recent_issues.map((i) => ( {(stats.recent_issues || []).map((i) => (
<tr key={i.id}> <tr key={i.id}>
<td>#{i.id}</td> <td>#{i.id}</td>
<td><a href={`/issues/${i.id}`}>{i.title}</a></td> <td><a href={`/issues/${i.id}`}>{i.title}</a></td>

View File

@@ -2,32 +2,28 @@ import { useState } from 'react'
import axios from 'axios' import axios from 'axios'
interface Props { interface Props {
wizardPort: number wizardBase: string
onComplete: () => void onComplete: () => void
} }
interface SetupForm { interface SetupForm {
// Admin
admin_username: string admin_username: string
admin_password: string admin_password: string
admin_email: string admin_email: string
admin_full_name: string admin_full_name: string
// Database
db_host: string db_host: string
db_port: number db_port: number
db_user: string db_user: string
db_password: string db_password: string
db_database: string db_database: string
// Backend backend_base_url: string
backend_url: string
// Default project
project_name: string project_name: string
project_description: string project_description: string
} }
const STEPS = ['欢迎', '数据库', '管理员', '项目', '完成'] const STEPS = ['欢迎', '数据库', '管理员', '项目', '完成']
export default function SetupWizardPage({ wizardPort, onComplete }: Props) { export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const [error, setError] = useState('') const [error, setError] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -42,13 +38,13 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
db_user: 'harborforge', db_user: 'harborforge',
db_password: 'harborforge_pass', db_password: 'harborforge_pass',
db_database: 'harborforge', db_database: 'harborforge',
backend_url: '', backend_base_url: 'http://127.0.0.1:8000',
project_name: 'Default', project_name: 'Default',
project_description: '默认项目', project_description: '默认项目',
}) })
const wizardApi = axios.create({ const wizardApi = axios.create({
baseURL: `http://127.0.0.1:${wizardPort}`, baseURL: wizardBase,
timeout: 5000, timeout: 5000,
}) })
@@ -63,7 +59,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
setStep(1) setStep(1)
} catch { } catch {
setWizardOk(false) setWizardOk(false)
setError(`无法连接 AbstractWizard (127.0.0.1:${wizardPort})。请确认已通过 SSH 隧道映射端口:\nssh -L ${wizardPort}:127.0.0.1:${wizardPort} user@server`) setError(`无法连接 AbstractWizard (${wizardBase})。\n请确认已通过 SSH 隧道映射端口:\nssh -L <wizard_port>:127.0.0.1:<wizard_port> user@server`)
} }
} }
@@ -72,6 +68,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
setSaving(true) setSaving(true)
try { try {
const config = { const config = {
initialized: true,
admin: { admin: {
username: form.admin_username, username: form.admin_username,
password: form.admin_password, password: form.admin_password,
@@ -85,7 +82,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
password: form.db_password, password: form.db_password,
database: form.db_database, database: form.db_database,
}, },
backend_url: form.backend_url || undefined, backend_url: form.backend_base_url || undefined,
default_project: form.project_name default_project: form.project_name
? { name: form.project_name, description: form.project_description } ? { name: form.project_name, description: form.project_description }
: undefined, : undefined,
@@ -95,8 +92,9 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })
// Switch wizard to readonly after setup if (form.backend_base_url) {
await wizardApi.put('/api/v1/mode', { mode: 'readonly' }).catch(() => {}) localStorage.setItem('HF_BACKEND_BASE_URL', form.backend_base_url)
}
setStep(4) setStep(4)
} catch (err: any) { } catch (err: any) {
@@ -129,7 +127,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
<p>Agent/</p> <p>Agent/</p>
<div className="setup-info"> <div className="setup-info">
<p> SSH AbstractWizard</p> <p> SSH AbstractWizard</p>
<code>ssh -L {wizardPort}:127.0.0.1:{wizardPort} user@your-server</code> <code>ssh -L &lt;wizard_port&gt;:127.0.0.1:&lt;wizard_port&gt; user@your-server</code>
</div> </div>
<button className="btn-primary" onClick={checkWizard}> <button className="btn-primary" onClick={checkWizard}>
Wizard Wizard
@@ -187,6 +185,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
<h2></h2> <h2></h2>
<p className="text-dim"></p> <p className="text-dim"></p>
<div className="setup-form"> <div className="setup-form">
<label> Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://127.0.0.1:8000" /></label>
<label> <input value={form.project_name} onChange={(e) => set('project_name', e.target.value)} placeholder="留空则跳过" /></label> <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> <label> <input value={form.project_description} onChange={(e) => set('project_description', e.target.value)} /></label>
</div> </div>
@@ -204,10 +203,11 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
<div className="setup-step-content"> <div className="setup-step-content">
<div className="setup-done"> <div className="setup-done">
<h2> </h2> <h2> </h2>
<p> AbstractWizardWizard </p> <p> AbstractWizard</p>
<p></p>
<div className="setup-info"> <div className="setup-info">
<p></p> <p></p>
<code>docker compose restart</code>
<p style={{ marginTop: '1rem' }}></p>
<p>: <strong>{form.admin_username}</strong></p> <p>: <strong>{form.admin_username}</strong></p>
</div> </div>
<button className="btn-primary" onClick={onComplete}> <button className="btn-primary" onClick={onComplete}>

View File

@@ -1,10 +1,15 @@
import axios from 'axios' import axios from 'axios'
const getApiBase = () => {
return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000'
}
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '/api', baseURL: getApiBase(),
}) })
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
config.baseURL = getApiBase()
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`