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:
parent
65d2904f1a
commit
34b85f68ae
|
|
@ -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 券金融平台 © 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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': '運営概要',
|
||||||
|
|
|
||||||
|
|
@ -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,19 +334,30 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
}} />
|
}} />
|
||||||
</button>
|
</button>
|
||||||
{/* Admin avatar */}
|
{/* Admin avatar + logout */}
|
||||||
<div style={{
|
<div
|
||||||
width: 32, height: 32,
|
style={{
|
||||||
borderRadius: '50%',
|
display: 'flex',
|
||||||
background: 'var(--color-primary)',
|
alignItems: 'center',
|
||||||
display: 'flex',
|
gap: 8,
|
||||||
alignItems: 'center',
|
cursor: 'pointer',
|
||||||
justifyContent: 'center',
|
}}
|
||||||
color: 'white',
|
onClick={logout}
|
||||||
fontSize: 14,
|
title={t('header_logout')}
|
||||||
fontWeight: 600,
|
>
|
||||||
}}>
|
<div style={{
|
||||||
A
|
width: 32, height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{user?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue