feat(admin-web): 登录页 + Auth Guard + API URL 切换域名

- 新建 /login 页面(邮箱/密码登录,对接 auth-context)
- AdminLayout 添加 auth guard:未登录自动跳转 /login
- api-client 默认 URL 从 localhost:8080 → https://api.gogenex.com
- Header 头像显示用户首字母,点击登出
- i18n 补充 header_logout (zh/en/ja)

API 联通验证(全部正常):
- POST /api/v1/auth/sms/send → 400 (手机号未注册)
- POST /api/v1/auth/login → 401 (密码错误)
- POST /api/v1/auth/register → 400 (验证码过期)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-02 02:57:45 -08:00
parent 65d2904f1a
commit 34b85f68ae
4 changed files with 195 additions and 14 deletions

View File

@ -0,0 +1,157 @@
'use client';
import React, { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { useAuth } from '@/lib/auth-context';
export default function LoginPage() {
const router = useRouter();
const { login, isAuthenticated } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// 已登录则跳转
if (isAuthenticated) {
router.replace('/dashboard');
return null;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
router.replace('/dashboard');
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '登录失败,请检查账号密码';
setError(msg);
} finally {
setLoading(false);
}
};
return (
<div style={styles.wrapper}>
<div style={styles.card}>
<div style={styles.logoRow}>
<Image src="/logo.svg" alt="Genex" width={40} height={40} />
<span style={styles.brand}>Genex Admin</span>
</div>
<h1 style={styles.title}></h1>
<form onSubmit={handleSubmit} style={styles.form}>
<label style={styles.label}> / </label>
<input
style={styles.input}
type="text"
placeholder="admin@gogenex.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
<label style={styles.label}></label>
<input
style={styles.input}
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <p style={styles.error}>{error}</p>}
<button type="submit" style={styles.btn} disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
<p style={styles.footer}>Genex &copy; 2025</p>
</div>
</div>
);
}
/* ---------- inline styles (no extra CSS file) ---------- */
const styles: Record<string, React.CSSProperties> = {
wrapper: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #F3F1FF 0%, #E8E5FF 50%, #F8F9FC 100%)',
},
card: {
width: 400,
padding: '40px 36px',
borderRadius: 16,
background: '#fff',
boxShadow: '0 8px 32px rgba(108,92,231,0.10)',
},
logoRow: {
display: 'flex',
alignItems: 'center',
gap: 10,
marginBottom: 8,
},
brand: {
fontSize: 20,
fontWeight: 700,
color: '#6C5CE7',
},
title: {
fontSize: 24,
fontWeight: 600,
color: '#262B3A',
margin: '16px 0 28px',
},
form: {
display: 'flex',
flexDirection: 'column' as const,
gap: 12,
},
label: {
fontSize: 14,
fontWeight: 500,
color: '#5C6478',
},
input: {
padding: '10px 14px',
fontSize: 15,
border: '1px solid #E4E7F0',
borderRadius: 8,
outline: 'none',
transition: 'border-color 0.2s',
},
error: {
fontSize: 13,
color: '#FF4757',
margin: 0,
},
btn: {
marginTop: 8,
padding: '12px 0',
fontSize: 16,
fontWeight: 600,
color: '#fff',
background: '#6C5CE7',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
transition: 'opacity 0.2s',
},
footer: {
textAlign: 'center' as const,
marginTop: 28,
fontSize: 12,
color: '#A0A8BE',
},
};

View File

@ -134,6 +134,7 @@ const translations: Record<Locale, Record<string, string>> = {
// ── Header ── // ── Header ──
'header_search_placeholder': '搜索用户/订单/交易...', 'header_search_placeholder': '搜索用户/订单/交易...',
'header_ai_assistant': 'AI 助手', 'header_ai_assistant': 'AI 助手',
'header_logout': '退出登录',
// ── Dashboard ── // ── Dashboard ──
'dashboard_title': '运营总览', 'dashboard_title': '运营总览',
@ -866,6 +867,7 @@ const translations: Record<Locale, Record<string, string>> = {
// ── Header ── // ── Header ──
'header_search_placeholder': 'Search users/orders/trades...', 'header_search_placeholder': 'Search users/orders/trades...',
'header_ai_assistant': 'AI Assistant', 'header_ai_assistant': 'AI Assistant',
'header_logout': 'Logout',
// ── Dashboard ── // ── Dashboard ──
'dashboard_title': 'Dashboard', 'dashboard_title': 'Dashboard',
@ -1598,6 +1600,7 @@ const translations: Record<Locale, Record<string, string>> = {
// ── Header ── // ── Header ──
'header_search_placeholder': 'ユーザー/注文/取引を検索...', 'header_search_placeholder': 'ユーザー/注文/取引を検索...',
'header_ai_assistant': 'AIアシスタント', 'header_ai_assistant': 'AIアシスタント',
'header_logout': 'ログアウト',
// ── Dashboard ── // ── Dashboard ──
'dashboard_title': '運営概要', 'dashboard_title': '運営概要',

View File

@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { t } from '@/i18n/locales'; import { t } from '@/i18n/locales';
import { useAuth } from '@/lib/auth-context';
/** /**
* D. Web管理前端 - * D. Web管理前端 -
@ -95,8 +96,17 @@ const navItems: NavItem[] = [
export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { isAuthenticated, isLoading, user, logout } = useAuth();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
// 未登录 → /login
if (!isLoading && !isAuthenticated) {
router.replace('/login');
return null;
}
// 加载中显示空白
if (isLoading) return null;
// Derive activeKey from current pathname // Derive activeKey from current pathname
const activeKey = pathname.replace(/^\//, '') || 'dashboard'; const activeKey = pathname.replace(/^\//, '') || 'dashboard';
@ -324,7 +334,17 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
borderRadius: '50%', borderRadius: '50%',
}} /> }} />
</button> </button>
{/* Admin avatar */} {/* Admin avatar + logout */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: 'pointer',
}}
onClick={logout}
title={t('header_logout')}
>
<div style={{ <div style={{
width: 32, height: 32, width: 32, height: 32,
borderRadius: '50%', borderRadius: '50%',
@ -336,7 +356,8 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
}}> }}>
A {user?.name?.charAt(0)?.toUpperCase() || 'A'}
</div>
</div> </div>
</div> </div>
</header> </header>

View File

@ -1,6 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.gogenex.com';
class ApiClient { class ApiClient {
private client: AxiosInstance; private client: AxiosInstance;