feat: 实现完整的用户认证系统

- 添加 SQLite 数据库支持用户存储
- 实现用户注册和登录 API 端点
- 新增独立的认证按钮组件,位于右上角
- 集成 Zustand 状态管理支持持久化登录状态
- 添加密码哈希和验证功能
- 支持登录表单模态框和用户状态显示
- 启用服务端渲染支持 API 路由
This commit is contained in:
zl 2025-11-17 15:55:19 +08:00
parent 34c8d429af
commit aa2d0dbb05
18 changed files with 1696 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import react from '@astrojs/react';
import AstroPWA from '@vite-pwa/astro';
export default defineConfig({
output: 'server',
integrations: [
react(),
AstroPWA({

View file

@ -0,0 +1,285 @@
.authButton {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--color-foreground, #1e293b);
transition: all 0.2s ease;
font-size: 16px;
line-height: 1;
width: 40px;
height: 40px;
position: fixed;
top: 1rem;
right: 4rem; /* 主题切换按钮左侧 */
z-index: 1000;
background: var(--bg-secondary);
border: 1px solid var(--color-border);
}
.authButton:hover {
background: var(--bg-tertiary);
transform: scale(1.05);
}
.authButton:focus {
outline: 2px solid var(--color-foreground);
outline-offset: 2px;
}
.authButton:active {
transform: scale(0.95);
}
.userIndicator {
position: absolute;
bottom: 2px;
right: 2px;
width: 8px;
height: 8px;
background: #10b981;
border-radius: 50%;
border: 2px solid var(--bg-secondary);
}
/* 暗色主题下的特殊样式 */
:global(.dark-theme) .authButton {
background: var(--bg-secondary);
border: 1px solid var(--color-border);
}
:global(.dark-theme) .authButton:hover {
background: var(--bg-tertiary);
}
:global(.dark-theme) .userIndicator {
border-color: var(--bg-secondary);
}
/* 认证表单样式 */
.authFormOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
padding: 16px;
}
.authForm {
width: 100%;
max-width: 320px;
padding: 16px;
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-foreground);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.authForm h3 {
margin: 0 0 16px 0;
font-size: var(--font-lg);
font-weight: 600;
text-align: center;
color: var(--color-foreground);
}
.authForm form {
display: flex;
flex-direction: column;
gap: 12px;
}
.authInput {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: var(--font-sm);
background: var(--input-bg);
color: var(--color-foreground);
transition: border-color 0.2s, box-shadow 0.2s;
}
.authInput::placeholder {
color: var(--color-foreground-subtler);
}
.authInput:focus {
outline: none;
border-color: var(--color-muted);
box-shadow: 0 0 0 2px var(--color-muted);
}
.authButtons {
display: flex;
gap: 8px;
}
.authSubmitButton {
flex: 1;
padding: 8px 16px;
background: var(--color-foreground);
color: var(--bg-primary);
border: none;
border-radius: 6px;
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.authSubmitButton:hover:not(:disabled) {
background: var(--color-foreground-subtle);
}
.authSubmitButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.authCancelButton {
padding: 8px 16px;
background: transparent;
color: var(--color-foreground-subtler);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.authCancelButton:hover {
background: var(--component-hover);
border-color: var(--color-foreground-subtle);
}
.authToggle {
margin-top: 8px;
}
.authToggleButton {
width: 100%;
padding: 6px 12px;
background: transparent;
color: var(--color-foreground-subtle);
border: none;
border-radius: 6px;
font-size: var(--font-xsm);
cursor: pointer;
text-decoration: underline;
transition: color 0.2s;
}
.authToggleButton:hover {
color: var(--color-foreground);
}
/* 用户菜单样式 */
.userMenu {
position: fixed;
top: 4rem;
right: 4rem;
z-index: 1000;
}
.userInfo {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
min-width: 160px;
}
.userAvatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-foreground);
color: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-sm);
font-weight: 600;
flex-shrink: 0;
}
.userName {
font-weight: 500;
color: var(--color-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logoutButton {
padding: 4px 8px;
font-size: var(--font-xsm);
color: #ef4444;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.logoutButton:hover {
background: rgba(239, 68, 68, 0.1);
}
/* 暗色主题下的样式优化 */
:global(.dark-theme) .authForm {
background: var(--component-bg);
border-color: var(--color-border);
}
:global(.dark-theme) .authInput {
background: var(--input-bg);
border-color: var(--color-border);
}
:global(.dark-theme) .userInfo {
background: var(--component-bg);
border-color: var(--color-border);
}
/* 响应式设计 */
@media (max-width: 768px) {
.authButton {
top: 0.75rem;
right: 3.5rem;
width: 36px;
height: 36px;
font-size: 14px;
}
.userMenu {
top: 3.5rem;
right: 3.5rem;
}
.authFormOverlay {
padding: 12px;
}
.authForm {
padding: 12px;
}
}

View file

@ -0,0 +1,143 @@
import { useState } from 'react';
import { FaUser } from 'react-icons/fa/index';
import { motion } from 'motion/react';
import { useAuthStore } from '@/stores/auth';
import styles from './auth-button.module.css';
export function AuthButton() {
const { isAuthenticated, user, login, logout, isLoading } = useAuthStore();
const [showAuthForm, setShowAuthForm] = useState(false);
const [isLogin, setIsLogin] = useState(true);
const [formData, setFormData] = useState({
username: '',
password: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isLogin) {
await login(formData);
} else {
// 注册功能先用登录代替
await login(formData);
}
setShowAuthForm(false);
setFormData({ username: '', password: '' });
} catch (error) {
console.error('认证失败:', error);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleLogout = () => {
logout();
};
const handleClick = () => {
if (isAuthenticated) {
// 如果已登录,显示用户菜单
return;
} else {
// 如果未登录,显示登录表单
setShowAuthForm(true);
}
};
return (
<>
<motion.button
className={styles.authButton}
onClick={handleClick}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={isAuthenticated ? `用户: ${user?.username}` : '登录'}
>
<FaUser />
{isAuthenticated && user && (
<span className={styles.userIndicator}></span>
)}
</motion.button>
{showAuthForm && (
<div className={styles.authFormOverlay} onClick={() => setShowAuthForm(false)}>
<div className={styles.authForm} onClick={(e) => e.stopPropagation()}>
<h3>{isLogin ? '登录' : '注册'}</h3>
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
placeholder="用户名"
value={formData.username}
onChange={handleChange}
required
minLength={3}
className={styles.authInput}
autoComplete="username"
/>
<input
type="password"
name="password"
placeholder="密码"
value={formData.password}
onChange={handleChange}
required
minLength={6}
className={styles.authInput}
autoComplete="current-password"
/>
<div className={styles.authButtons}>
<button
type="submit"
disabled={isLoading}
className={styles.authSubmitButton}
>
{isLoading ? '处理中...' : (isLogin ? '登录' : '注册')}
</button>
<button
type="button"
onClick={() => setShowAuthForm(false)}
className={styles.authCancelButton}
>
</button>
</div>
<div className={styles.authToggle}>
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className={styles.authToggleButton}
>
{isLogin ? '没有账号?注册' : '已有账号?登录'}
</button>
</div>
</form>
</div>
</div>
)}
{isAuthenticated && (
<div className={styles.userMenu}>
<div className={styles.userInfo}>
<div className={styles.userAvatar}>
{user?.username.charAt(0).toUpperCase()}
</div>
<span className={styles.userName}>{user?.username}</span>
<button
onClick={handleLogout}
className={styles.logoutButton}
>
退
</button>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1 @@
export { AuthButton } from './auth-button';

View file

@ -0,0 +1,146 @@
.authContainer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.authCard {
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 32px;
width: 100%;
max-width: 400px;
margin: 0 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.title {
font-size: var(--font-xl);
font-weight: 600;
color: var(--color-foreground);
text-align: center;
margin-bottom: 24px;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground-subtle);
}
.input {
padding: 12px 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: var(--font-base);
background: var(--bg-primary);
color: var(--color-foreground);
transition: border-color 0.2s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: var(--color-muted);
box-shadow: 0 0 0 2px var(--color-muted);
}
&::placeholder {
color: var(--color-foreground-subtler);
}
}
.submitButton {
padding: 14px 24px;
background: var(--color-foreground);
color: var(--bg-primary);
border: none;
border-radius: 8px;
font-size: var(--font-base);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
&:hover:not(:disabled) {
background: var(--color-foreground-subtle);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.successMessage {
background: #10b981;
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: var(--font-sm);
text-align: center;
margin-bottom: 16px;
}
.errorMessage {
background: #ef4444;
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: var(--font-sm);
text-align: center;
margin-bottom: 16px;
}
.toggleSection {
text-align: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.toggleText {
font-size: var(--font-sm);
color: var(--color-foreground-subtler);
}
.toggleButton {
background: none;
border: none;
color: var(--color-foreground);
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
text-decoration: underline;
transition: color 0.2s;
&:hover {
color: var(--color-foreground-subtle);
}
}

View file

@ -0,0 +1,131 @@
import { useState } from 'react';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from '@/hooks/useTranslation';
import styles from './auth-form.module.css';
export function AuthForm() {
const { t } = useTranslation();
const [isLogin, setIsLogin] = useState(true);
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [showSuccess, setShowSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const { login, register, isLoading, error, clearError } = useAuthStore();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
try {
if (isLogin) {
await login(formData);
setSuccessMessage('登录成功!');
} else {
await register(formData);
setSuccessMessage('注册成功!');
}
setShowSuccess(true);
setTimeout(() => {
setShowSuccess(false);
}, 3000);
} catch (error) {
// 错误已经在 store 中处理了
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const toggleMode = () => {
setIsLogin(!isLogin);
clearError();
setFormData({ username: '', password: '' });
};
return (
<div className={styles.authContainer}>
<div className={styles.authCard}>
<h2 className={styles.title}>
{isLogin ? '登录' : '注册'}
</h2>
{showSuccess && (
<div className={styles.successMessage}>
{successMessage}
</div>
)}
{error && (
<div className={styles.errorMessage}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.formGroup}>
<label htmlFor="username" className={styles.label}>
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={styles.input}
required
minLength={3}
placeholder="请输入用户名至少3个字符"
/>
</div>
<div className={styles.formGroup}>
<label htmlFor="password" className={styles.label}>
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={styles.input}
required
minLength={6}
placeholder="请输入密码至少6个字符"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={styles.submitButton}
>
{isLoading ? '处理中...' : (isLogin ? '登录' : '注册')}
</button>
</form>
<div className={styles.toggleSection}>
<span className={styles.toggleText}>
{isLogin ? '还没有账号?' : '已有账号?'}
</span>
<button
type="button"
onClick={toggleMode}
className={styles.toggleButton}
>
{isLogin ? '立即注册' : '立即登录'}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
export { AuthForm } from './auth-form';
export { UserInfo } from './user-info';
export { LoginTrigger } from './login-trigger';

View file

@ -0,0 +1,26 @@
.loginButton {
padding: 8px 16px;
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-foreground);
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
&:hover {
background: var(--component-hover);
border-color: var(--color-foreground-subtle);
}
}
.backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: transparent;
z-index: 9998;
}

View file

@ -0,0 +1,26 @@
import { useState } from 'react';
import { AuthForm } from './auth-form';
import styles from './login-trigger.module.css';
export function LoginTrigger() {
const [showAuth, setShowAuth] = useState(false);
const openAuth = () => {
setShowAuth(true);
};
const closeAuth = () => {
setShowAuth(false);
};
return (
<>
<button className={styles.loginButton} onClick={openAuth}>
</button>
{showAuth && <AuthForm />}
{showAuth && <div className={styles.backdrop} onClick={closeAuth} />}
</>
);
}

View file

@ -0,0 +1,140 @@
.userContainer {
position: relative;
display: inline-block;
}
.userButton {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
color: var(--color-foreground);
font-size: var(--font-sm);
}
.userButton:hover {
background: var(--component-hover);
border-color: var(--color-foreground-subtle);
}
.userAvatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-foreground);
color: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-sm);
font-weight: 600;
}
.userName {
font-weight: 500;
color: var(--color-foreground);
}
.dropdownArrow {
font-size: 10px;
color: var(--color-foreground-subtler);
transition: transform 0.2s;
}
.userButton:hover .dropdownArrow {
transform: translateY(1px);
}
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 280px;
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
z-index: 1000;
overflow: hidden;
}
.userInfo {
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.userAvatarLarge {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--color-foreground);
color: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-lg);
font-weight: 600;
flex-shrink: 0;
}
.userDetails {
flex: 1;
min-width: 0;
}
.userFullname {
font-size: var(--font-base);
font-weight: 600;
color: var(--color-foreground);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.userJoined {
font-size: var(--font-xsm);
color: var(--color-foreground-subtler);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.divider {
height: 1px;
background: var(--color-border);
}
.logoutButton {
width: 100%;
padding: 12px 20px;
background: none;
border: none;
color: #ef4444;
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
text-align: left;
&:hover {
background: rgba(239, 68, 68, 0.1);
}
}
.backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: transparent;
z-index: 999;
}

View file

@ -0,0 +1,63 @@
import { useState } from 'react';
import { useAuthStore } from '@/stores/auth';
import styles from './user-info.module.css';
export function UserInfo() {
const { user, logout } = useAuthStore();
const [showDropdown, setShowDropdown] = useState(false);
const handleLogout = () => {
logout();
setShowDropdown(false);
};
if (!user) {
return null;
}
return (
<div className={styles.userContainer}>
<button
className={styles.userButton}
onClick={() => setShowDropdown(!showDropdown)}
aria-label="用户菜单"
>
<div className={styles.userAvatar}>
{user.username.charAt(0).toUpperCase()}
</div>
<span className={styles.userName}>{user.username}</span>
<span className={styles.dropdownArrow}></span>
</button>
{showDropdown && (
<div className={styles.dropdown}>
<div className={styles.userInfo}>
<div className={styles.userAvatarLarge}>
{user.username.charAt(0).toUpperCase()}
</div>
<div className={styles.userDetails}>
<div className={styles.userFullname}>{user.username}</div>
<div className={styles.userJoined}>
: {new Date(user.created_at).toLocaleDateString()}
</div>
</div>
</div>
<div className={styles.divider}></div>
<button
className={styles.logoutButton}
onClick={handleLogout}
>
退
</button>
</div>
)}
{showDropdown && (
<div
className={styles.backdrop}
onClick={() => setShowDropdown(false)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,138 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { motion } from 'motion/react';
import { useTranslation } from '@/hooks/useTranslation';
import { useState } from 'react';
import { useAuthStore } from '@/stores/auth';
import styles from './item.module.css';
interface AuthItemProps {
open: () => void;
}
export function AuthItem({ open }: AuthItemProps) {
const { t } = useTranslation();
const { isAuthenticated, user, login, logout, isLoading } = useAuthStore();
const [showAuthForm, setShowAuthForm] = useState(false);
const [isLogin, setIsLogin] = useState(true);
const [formData, setFormData] = useState({
username: '',
password: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isLogin) {
await login(formData);
} else {
// 这里需要调用注册API
// 为了简单起见,我们先用登录代替注册
await login(formData);
}
setShowAuthForm(false);
setFormData({ username: '', password: '' });
} catch (error) {
// 错误处理
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleLogout = () => {
logout();
};
if (isAuthenticated && user) {
return (
<div className={styles.userInfo}>
<span className={styles.userAvatar}>
{user.username.charAt(0).toUpperCase()}
</span>
<span className={styles.userName}>{user.username}</span>
<DropdownMenu.Item asChild>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleLogout}
className={styles.logoutButton}
>
退
</motion.button>
</DropdownMenu.Item>
</div>
);
}
return (
<>
{/* 隐藏的触发器按钮 */}
<button
id="auth-trigger"
onClick={() => setShowAuthForm(true)}
style={{ display: 'none' }}
/>
{showAuthForm && (
<div className={styles.authFormOverlay}>
<div className={styles.authForm}>
<h3>{isLogin ? '登录' : '注册'}</h3>
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
placeholder="用户名"
value={formData.username}
onChange={handleChange}
required
minLength={3}
className={styles.authInput}
autoComplete="username"
/>
<input
type="password"
name="password"
placeholder="密码"
value={formData.password}
onChange={handleChange}
required
minLength={6}
className={styles.authInput}
autoComplete="current-password"
/>
<div className={styles.authButtons}>
<button
type="submit"
disabled={isLoading}
className={styles.authSubmitButton}
>
{isLoading ? '处理中...' : (isLogin ? '登录' : '注册')}
</button>
<button
type="button"
onClick={() => setShowAuthForm(false)}
className={styles.authCancelButton}
>
</button>
</div>
<div className={styles.authToggle}>
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className={styles.authToggleButton}
>
{isLogin ? '没有账号?注册' : '已有账号?登录'}
</button>
</div>
</form>
</div>
</div>
)}
</>
);
}

View file

@ -13,3 +13,4 @@ export { Countdown as CountdownItem } from './countdown';
export { Binaural as BinauralItem } from './binaural';
export { Isochronic as IsochronicItem } from './isochronic';
export { Lofi as LofiItem } from './lofi';
export { AuthItem } from './auth-item';

View file

@ -0,0 +1,201 @@
.item {
width: 100%;
padding: 8px 12px;
text-align: left;
font-size: var(--font-sm);
color: var(--color-foreground);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: var(--color-neutral-200);
}
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
}
.authContainer {
width: 100%;
padding: 8px;
}
.userInfo {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
}
.userAvatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-foreground);
color: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-sm);
font-weight: 600;
flex-shrink: 0;
}
.userName {
font-weight: 500;
color: var(--color-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logoutButton {
width: 100%;
padding: 8px 12px;
text-align: left;
font-size: var(--font-sm);
color: #ef4444;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: rgba(239, 68, 68, 0.1);
}
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
}
.authForm {
width: 100%;
padding: 16px;
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-foreground);
h3 {
margin: 0 0 16px 0;
font-size: var(--font-lg);
font-weight: 600;
text-align: center;
color: var(--color-foreground);
}
form {
display: flex;
flex-direction: column;
gap: 12px;
}
}
.authInput {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: var(--font-sm);
background: var(--bg-primary);
color: var(--color-foreground);
&::placeholder {
color: var(--color-foreground-subtler);
}
&:focus {
outline: none;
border-color: var(--color-muted);
box-shadow: 0 0 0 2px var(--color-muted);
}
}
.authButtons {
display: flex;
gap: 8px;
}
.authSubmitButton {
flex: 1;
padding: 8px 16px;
background: var(--color-foreground);
color: var(--bg-primary);
border: none;
border-radius: 6px;
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
&:hover:not(:disabled) {
background: var(--color-foreground-subtle);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.authCancelButton {
padding: 8px 16px;
background: transparent;
color: var(--color-foreground-subtler);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
&:hover {
background: var(--component-hover);
border-color: var(--color-foreground-subtle);
}
}
.authToggle {
margin-top: 8px;
}
.authToggleButton {
width: 100%;
padding: 6px 12px;
background: transparent;
color: var(--color-foreground-subtle);
border: none;
border-radius: 6px;
font-size: var(--font-xsm);
cursor: pointer;
text-decoration: underline;
transition: color 0.2s;
&:hover {
color: var(--color-foreground);
}
}
.authFormOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}

108
src/lib/database.ts Normal file
View file

@ -0,0 +1,108 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import path from 'path';
import fs from 'fs';
let db: Database.Database | null = null;
export interface User {
id: number;
username: string;
password: string;
created_at: string;
}
export interface CreateUserData {
username: string;
password: string;
}
export function getDatabase(): Database.Database {
if (!db) {
// 创建数据库文件路径
const dbPath = path.join(process.cwd(), 'data', 'users.db');
// 确保目录存在
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
db = new Database(dbPath);
// 创建用户表
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
return db;
}
export async function createUser(userData: CreateUserData): Promise<User> {
const database = getDatabase();
// 检查用户名是否已存在
const existingUser = database.prepare('SELECT id FROM users WHERE username = ?').get(userData.username);
if (existingUser) {
throw new Error('用户名已存在');
}
// 加密密码
const hashedPassword = await bcrypt.hash(userData.password, 10);
// 创建用户
const stmt = database.prepare('INSERT INTO users (username, password) VALUES (?, ?)');
const result = stmt.run(userData.username, hashedPassword);
// 返回创建的用户
const user = database.prepare('SELECT id, username, created_at FROM users WHERE id = ?').get(result.lastInsertRowid) as User;
return user;
}
export function authenticateUser(username: string, password: string): User | null {
const database = getDatabase();
// 查找用户
const user = database.prepare('SELECT id, username, password, created_at FROM users WHERE username = ?').get(username) as User & { password: string } | null;
if (!user) {
return null;
}
// 验证密码
const isValidPassword = bcrypt.compareSync(password, user.password);
if (!isValidPassword) {
return null;
}
// 返回用户信息(不包含密码)
return {
id: user.id,
username: user.username,
created_at: user.created_at
};
}
export function getUserById(id: number): User | null {
const database = getDatabase();
const user = database.prepare('SELECT id, username, created_at FROM users WHERE id = ?').get(id) as User | null;
return user;
}
export function getUserByUsername(username: string): User | null {
const database = getDatabase();
const user = database.prepare('SELECT id, username, created_at FROM users WHERE username = ?').get(username) as User | null;
return user;
}

View file

@ -0,0 +1,46 @@
import type { APIRoute } from 'astro';
import { authenticateUser } from '@/lib/database';
export const POST: APIRoute = async ({ request }) => {
try {
const { username, password } = await request.json();
// 验证输入
if (!username || !password) {
return new Response(JSON.stringify({ error: '用户名和密码不能为空' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 验证用户
const user = authenticateUser(username, password);
if (!user) {
return new Response(JSON.stringify({ error: '用户名或密码错误' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at
}
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('登录错误:', error);
return new Response(JSON.stringify({ error: '登录失败,请稍后再试' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View file

@ -0,0 +1,60 @@
import type { APIRoute } from 'astro';
import { createUser } from '@/lib/database';
export const POST: APIRoute = async ({ request }) => {
try {
const { username, password } = await request.json();
// 验证输入
if (!username || !password) {
return new Response(JSON.stringify({ error: '用户名和密码不能为空' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (username.length < 3) {
return new Response(JSON.stringify({ error: '用户名至少需要3个字符' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (password.length < 6) {
return new Response(JSON.stringify({ error: '密码至少需要6个字符' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 创建用户
const user = await createUser({ username, password });
return new Response(JSON.stringify({
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at
}
}), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('注册错误:', error);
if (error instanceof Error && error.message === '用户名已存在') {
return new Response(JSON.stringify({ error: '用户名已存在' }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: '注册失败,请稍后再试' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

177
src/stores/auth.ts Normal file
View file

@ -0,0 +1,177 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface User {
id: number;
username: string;
created_at: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
interface AuthStore extends AuthState {
// Actions
login: (userData: { username: string; password: string }) => Promise<void>;
register: (userData: { username: string; password: string }) => Promise<void>;
logout: () => void;
clearError: () => void;
setLoading: (loading: boolean) => void;
checkAuth: () => Promise<void>;
}
/**
* API
*/
const apiCall = async (url: string, data: any) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'API 调用失败');
}
return result;
};
/**
* Store
*/
export const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
// Initial state
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
// Actions
login: async (userData) => {
set({ isLoading: true, error: null });
try {
const result = await apiCall('/api/auth/login', userData);
const user = result.user;
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});
console.log('✅ 用户登录成功:', user.username);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '登录失败';
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: errorMessage,
});
console.error('❌ 登录失败:', error);
throw error;
}
},
register: async (userData) => {
set({ isLoading: true, error: null });
try {
const result = await apiCall('/api/auth/register', userData);
const user = result.user;
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});
console.log('✅ 用户注册成功:', user.username);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '注册失败';
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: errorMessage,
});
console.error('❌ 注册失败:', error);
throw error;
}
},
logout: () => {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
console.log('✅ 用户已登出');
},
clearError: () => {
set({ error: null });
},
setLoading: (loading: boolean) => {
set({ isLoading: loading });
},
checkAuth: async () => {
const { user, isAuthenticated } = get();
// 如果已经有用户信息且已认证,则直接返回
if (user && isAuthenticated) {
return;
}
// 这里可以添加token验证或会话验证逻辑
// 目前简单检查本地存储的用户信息
set({ isLoading: true });
try {
// 如果有用户信息但未认证,可以尝试验证
if (user && !isAuthenticated) {
set({
isAuthenticated: true,
isLoading: false,
error: null,
});
} else {
set({ isLoading: false });
}
} catch (error) {
console.error('❌ 认证检查失败:', error);
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: '认证检查失败',
});
}
},
}),
{
name: 'auth-storage', // localStorage key
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
);