Compare commits
1 Commits
54d4c4379a
...
f8fac48fcc
| Author | SHA1 | Date | |
|---|---|---|---|
| f8fac48fcc |
@@ -5,7 +5,9 @@ COPY package.json package-lock.json* ./
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_API_BASE=/api
|
ARG VITE_API_BASE=/api
|
||||||
|
ARG VITE_WIZARD_PORT=18080
|
||||||
ENV VITE_API_BASE=$VITE_API_BASE
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
ENV VITE_WIZARD_PORT=$VITE_WIZARD_PORT
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage — lightweight static server, no nginx
|
# 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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import Sidebar from '@/components/Sidebar'
|
import Sidebar from '@/components/Sidebar'
|
||||||
import LoginPage from '@/pages/LoginPage'
|
import LoginPage from '@/pages/LoginPage'
|
||||||
|
import SetupWizardPage from '@/pages/SetupWizardPage'
|
||||||
import DashboardPage from '@/pages/DashboardPage'
|
import DashboardPage from '@/pages/DashboardPage'
|
||||||
import IssuesPage from '@/pages/IssuesPage'
|
import IssuesPage from '@/pages/IssuesPage'
|
||||||
import IssueDetailPage from '@/pages/IssueDetailPage'
|
import IssueDetailPage from '@/pages/IssueDetailPage'
|
||||||
@@ -11,12 +13,41 @@ 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'
|
||||||
|
|
||||||
|
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
||||||
|
|
||||||
|
type AppState = 'checking' | 'setup' | 'ready'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [appState, setAppState] = useState<AppState>('checking')
|
||||||
const { user, loading, login, logout } = useAuth()
|
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>
|
if (loading) return <div className="loading">加载中...</div>
|
||||||
|
|
||||||
|
// Not logged in
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -160,3 +160,26 @@ dd { font-size: .9rem; }
|
|||||||
|
|
||||||
/* Text dim helper */
|
/* Text dim helper */
|
||||||
.text-dim { color: var(--text-dim); font-size: .85rem; }
|
.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