From 72b9c25262b5d927f70a9e91a994b5d77beb85b7 Mon Sep 17 00:00:00 2001 From: maoshen Date: Sun, 12 Apr 2026 11:20:35 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增登录页面 (Login.jsx) 和注册页面 (Register.jsx) - 实现 JWT 认证,支持登录/注册/登出 - 登录后导航栏显示用户名和登出按钮 - 修复 API 路径重复问题 (/api/api/ → /api/) - 优化登录/注册页面 UI 设计 --- city-manual/frontend/src/App.css | 106 +++++++++++++++ city-manual/frontend/src/App.jsx | 58 ++++++-- city-manual/frontend/src/api.js | 2 +- city-manual/frontend/src/pages/Login.css | 133 ++++++++++++++++++ city-manual/frontend/src/pages/Login.jsx | 87 ++++++++++++ city-manual/frontend/src/pages/Register.css | 124 +++++++++++++++++ city-manual/frontend/src/pages/Register.jsx | 141 ++++++++++++++++++++ 7 files changed, 638 insertions(+), 13 deletions(-) create mode 100644 city-manual/frontend/src/pages/Login.css create mode 100644 city-manual/frontend/src/pages/Login.jsx create mode 100644 city-manual/frontend/src/pages/Register.css create mode 100644 city-manual/frontend/src/pages/Register.jsx diff --git a/city-manual/frontend/src/App.css b/city-manual/frontend/src/App.css index f90339d..a6bf5c8 100644 --- a/city-manual/frontend/src/App.css +++ b/city-manual/frontend/src/App.css @@ -1,3 +1,109 @@ +/* Navigation Bar */ +.navbar { + background: white; + border-bottom: 1px solid var(--border); + padding: 16px 0; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.navbar .container { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +.logo { + font-size: 24px; + font-weight: 700; + color: var(--text-h); + text-decoration: none; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + } +} + +.nav-links { + display: flex; + align-items: center; + gap: 24px; +} + +.nav-links a { + color: var(--text); + text-decoration: none; + font-weight: 500; + transition: color 0.2s; + + &:hover { + color: var(--accent); + } +} + +.btn-login { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white !important; + padding: 10px 20px; + border-radius: 8px; + font-weight: 600; + transition: transform 0.2s, box-shadow 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); + } +} + +.user-menu { + display: flex; + align-items: center; + gap: 16px; +} + +.username { + color: var(--text); + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; +} + +.btn-logout { + background: #f5f5f5; + color: var(--text); + border: none; + padding: 10px 20px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #e5e5e5; + color: var(--text-h); + } +} + +@media (max-width: 768px) { + .nav-links { + gap: 16px; + font-size: 14px; + } + + .btn-login, + .btn-logout { + padding: 8px 16px; + font-size: 14px; + } +} + .counter { font-size: 16px; padding: 5px 10px; diff --git a/city-manual/frontend/src/App.jsx b/city-manual/frontend/src/App.jsx index 88e7b71..5070d56 100644 --- a/city-manual/frontend/src/App.jsx +++ b/city-manual/frontend/src/App.jsx @@ -1,10 +1,45 @@ -import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react-router-dom'; import Home from './pages/Home'; import Cities from './pages/Cities'; import RegionDetail from './pages/RegionDetail'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import api from './api'; import './App.css'; function App() { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [username, setUsername] = useState(''); + + useEffect(() => { + checkAuth(); + }, []); + + const checkAuth = async () => { + const token = localStorage.getItem('access_token'); + if (token) { + try { + const response = await api.get('/users/me/'); + setIsLoggedIn(true); + setUsername(response.data.username); + } catch (error) { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + setIsLoggedIn(false); + setUsername(''); + } + } + }; + + const handleLogout = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + setIsLoggedIn(false); + setUsername(''); + window.location.href = '/'; + }; + return (
@@ -18,7 +53,14 @@ function App() { 城市 文章 服务 - 登录 + {isLoggedIn ? ( +
+ 👤 {username} + +
+ ) : ( + 登录 + )}
@@ -32,8 +74,8 @@ function App() { } /> } /> } /> - } /> - } /> + } /> + } /> @@ -57,12 +99,4 @@ function ServiceDetail() { return

服务详情页

开发中...

; } -function Login() { - return

登录

开发中...

; -} - -function Register() { - return

注册

开发中...

; -} - export default App; diff --git a/city-manual/frontend/src/api.js b/city-manual/frontend/src/api.js index b811d00..2ecae72 100644 --- a/city-manual/frontend/src/api.js +++ b/city-manual/frontend/src/api.js @@ -1,6 +1,6 @@ import axios from 'axios'; -const API_BASE = 'http://localhost:8000/api'; +const API_BASE = '/api'; const api = axios.create({ baseURL: API_BASE, diff --git a/city-manual/frontend/src/pages/Login.css b/city-manual/frontend/src/pages/Login.css new file mode 100644 index 0000000..1d16be6 --- /dev/null +++ b/city-manual/frontend/src/pages/Login.css @@ -0,0 +1,133 @@ +.login-page { + min-height: calc(100vh - 200px); + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 40px 20px; +} + +.login-container { + width: 100%; + max-width: 420px; +} + +.login-box { + background: white; + border-radius: 16px; + padding: 40px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.login-title { + text-align: center; + color: #333; + margin-bottom: 8px; + font-size: 28px; +} + +.login-subtitle { + text-align: center; + color: #666; + margin-bottom: 32px; + font-size: 14px; +} + +.error-message { + background: #fee; + color: #c33; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 24px; + font-size: 14px; + border-left: 3px solid #c33; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 600; + color: #444; + font-size: 14px; +} + +.form-group input { + padding: 12px 16px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 16px; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: #667eea; +} + +.form-group input:disabled { + background: #f5f5f5; + cursor: not-allowed; +} + +.login-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 14px; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + margin-top: 8px; +} + +.login-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); +} + +.login-btn:active:not(:disabled) { + transform: translateY(0); +} + +.login-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.login-footer { + margin-top: 32px; + text-align: center; + color: #666; + font-size: 14px; +} + +.login-footer a { + color: #667eea; + text-decoration: none; + font-weight: 600; +} + +.login-footer a:hover { + text-decoration: underline; +} + +.demo-hint { + margin-top: 16px; + padding: 12px; + background: #f0f4ff; + border-radius: 8px; + color: #667eea; + font-size: 13px; +} diff --git a/city-manual/frontend/src/pages/Login.jsx b/city-manual/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..978a8d0 --- /dev/null +++ b/city-manual/frontend/src/pages/Login.jsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import api from '../api'; +import './Login.css'; + +function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const response = await api.post('/token/', { + username, + password + }); + + if (response.data.access) { + localStorage.setItem('access_token', response.data.access); + localStorage.setItem('refresh_token', response.data.refresh); + navigate('/'); + } + } catch (err) { + setError(err.response?.data?.detail || '登录失败,请检查用户名和密码'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

📖 登录城市手册

+

记录每座城市的独特魅力

+ + {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + placeholder="请输入用户名" + required + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="请输入密码" + required + disabled={loading} + /> +
+ + +
+ +
+

还没有账号?立即注册

+

💡 测试账号:demo / demo123

+
+
+
+
+ ); +} + +export default Login; diff --git a/city-manual/frontend/src/pages/Register.css b/city-manual/frontend/src/pages/Register.css new file mode 100644 index 0000000..f9269ca --- /dev/null +++ b/city-manual/frontend/src/pages/Register.css @@ -0,0 +1,124 @@ +.register-page { + min-height: calc(100vh - 200px); + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 40px 20px; +} + +.register-container { + width: 100%; + max-width: 420px; +} + +.register-box { + background: white; + border-radius: 16px; + padding: 40px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.register-title { + text-align: center; + color: #333; + margin-bottom: 8px; + font-size: 28px; +} + +.register-subtitle { + text-align: center; + color: #666; + margin-bottom: 32px; + font-size: 14px; +} + +.error-message { + background: #fee; + color: #c33; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 24px; + font-size: 14px; + border-left: 3px solid #c33; +} + +.register-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 600; + color: #444; + font-size: 14px; +} + +.form-group input { + padding: 12px 16px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 16px; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: #667eea; +} + +.form-group input:disabled { + background: #f5f5f5; + cursor: not-allowed; +} + +.register-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 14px; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + margin-top: 8px; +} + +.register-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); +} + +.register-btn:active:not(:disabled) { + transform: translateY(0); +} + +.register-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.register-footer { + margin-top: 32px; + text-align: center; + color: #666; + font-size: 14px; +} + +.register-footer a { + color: #667eea; + text-decoration: none; + font-weight: 600; +} + +.register-footer a:hover { + text-decoration: underline; +} diff --git a/city-manual/frontend/src/pages/Register.jsx b/city-manual/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..5e448a6 --- /dev/null +++ b/city-manual/frontend/src/pages/Register.jsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import api from '../api'; +import './Register.css'; + +function Register() { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('两次输入的密码不一致'); + return; + } + + if (password.length < 6) { + setError('密码长度至少为 6 位'); + return; + } + + setLoading(true); + + try { + await api.post('/register/', { + username, + email, + password + }); + + // 注册成功后自动登录 + const loginResponse = await api.post('/token/', { + username, + password + }); + + if (loginResponse.data.access) { + localStorage.setItem('access_token', loginResponse.data.access); + localStorage.setItem('refresh_token', loginResponse.data.refresh); + navigate('/'); + } + } catch (err) { + const errorMsg = err.response?.data; + if (errorMsg?.username) { + setError('用户名:' + (Array.isArray(errorMsg.username) ? errorMsg.username[0] : errorMsg.username)); + } else if (errorMsg?.email) { + setError('邮箱:' + (Array.isArray(errorMsg.email) ? errorMsg.email[0] : errorMsg.email)); + } else if (errorMsg?.password) { + setError('密码:' + (Array.isArray(errorMsg.password) ? errorMsg.password[0] : errorMsg.password)); + } else { + setError('注册失败,请稍后重试'); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

📖 注册城市手册

+

加入我们,记录每座城市的独特魅力

+ + {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + placeholder="请输入用户名" + required + disabled={loading} + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="请输入邮箱" + required + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="请输入密码(至少 6 位)" + required + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="请再次输入密码" + required + disabled={loading} + /> +
+ + +
+ +
+

已有账号?立即登录

+
+
+
+
+ ); +} + +export default Register;