diff --git a/astro.config.mjs b/astro.config.mjs index ebd6454..d4bf9d2 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,7 @@ import react from '@astrojs/react'; import AstroPWA from '@vite-pwa/astro'; export default defineConfig({ + output: 'server', integrations: [ react(), AstroPWA({ diff --git a/src/components/auth-button/auth-button.module.css b/src/components/auth-button/auth-button.module.css new file mode 100644 index 0000000..8453d28 --- /dev/null +++ b/src/components/auth-button/auth-button.module.css @@ -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; + } +} \ No newline at end of file diff --git a/src/components/auth-button/auth-button.tsx b/src/components/auth-button/auth-button.tsx new file mode 100644 index 0000000..784357a --- /dev/null +++ b/src/components/auth-button/auth-button.tsx @@ -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) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleLogout = () => { + logout(); + }; + + const handleClick = () => { + if (isAuthenticated) { + // 如果已登录,显示用户菜单 + return; + } else { + // 如果未登录,显示登录表单 + setShowAuthForm(true); + } + }; + + return ( + <> + + + {isAuthenticated && user && ( + + )} + + + {showAuthForm && ( +
setShowAuthForm(false)}> +
e.stopPropagation()}> +

{isLogin ? '登录' : '注册'}

+
+ + +
+ + +
+
+ +
+
+
+
+ )} + + {isAuthenticated && ( +
+
+
+ {user?.username.charAt(0).toUpperCase()} +
+ {user?.username} + +
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/auth-button/index.ts b/src/components/auth-button/index.ts new file mode 100644 index 0000000..b5a5a0e --- /dev/null +++ b/src/components/auth-button/index.ts @@ -0,0 +1 @@ +export { AuthButton } from './auth-button'; \ No newline at end of file diff --git a/src/components/auth/auth-form.module.css b/src/components/auth/auth-form.module.css new file mode 100644 index 0000000..02f10b0 --- /dev/null +++ b/src/components/auth/auth-form.module.css @@ -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); + } +} \ No newline at end of file diff --git a/src/components/auth/auth-form.tsx b/src/components/auth/auth-form.tsx new file mode 100644 index 0000000..a11bd58 --- /dev/null +++ b/src/components/auth/auth-form.tsx @@ -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) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const toggleMode = () => { + setIsLogin(!isLogin); + clearError(); + setFormData({ username: '', password: '' }); + }; + + return ( +
+
+

+ {isLogin ? '登录' : '注册'} +

+ + {showSuccess && ( +
+ {successMessage} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ + +
+ +
+ + {isLogin ? '还没有账号?' : '已有账号?'} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts new file mode 100644 index 0000000..2ee3eae --- /dev/null +++ b/src/components/auth/index.ts @@ -0,0 +1,3 @@ +export { AuthForm } from './auth-form'; +export { UserInfo } from './user-info'; +export { LoginTrigger } from './login-trigger'; \ No newline at end of file diff --git a/src/components/auth/login-trigger.module.css b/src/components/auth/login-trigger.module.css new file mode 100644 index 0000000..ff74f8b --- /dev/null +++ b/src/components/auth/login-trigger.module.css @@ -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; +} \ No newline at end of file diff --git a/src/components/auth/login-trigger.tsx b/src/components/auth/login-trigger.tsx new file mode 100644 index 0000000..cd85fc3 --- /dev/null +++ b/src/components/auth/login-trigger.tsx @@ -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 ( + <> + + + {showAuth && } + {showAuth &&
} + + ); +} \ No newline at end of file diff --git a/src/components/auth/user-info.module.css b/src/components/auth/user-info.module.css new file mode 100644 index 0000000..f9d61d3 --- /dev/null +++ b/src/components/auth/user-info.module.css @@ -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; +} \ No newline at end of file diff --git a/src/components/auth/user-info.tsx b/src/components/auth/user-info.tsx new file mode 100644 index 0000000..2fa5108 --- /dev/null +++ b/src/components/auth/user-info.tsx @@ -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 ( +
+ + + {showDropdown && ( +
+
+
+ {user.username.charAt(0).toUpperCase()} +
+
+
{user.username}
+
+ 加入时间: {new Date(user.created_at).toLocaleDateString()} +
+
+
+
+ +
+ )} + + {showDropdown && ( +
setShowDropdown(false)} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/toolbar/menu/items/auth-item.tsx b/src/components/toolbar/menu/items/auth-item.tsx new file mode 100644 index 0000000..5533148 --- /dev/null +++ b/src/components/toolbar/menu/items/auth-item.tsx @@ -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) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleLogout = () => { + logout(); + }; + + if (isAuthenticated && user) { + return ( +
+ + {user.username.charAt(0).toUpperCase()} + + {user.username} + + + 退出登录 + + +
+ ); + } + + return ( + <> + {/* 隐藏的触发器按钮 */} + + +
+
+ +
+ +
+ + )} + + ); +} \ No newline at end of file diff --git a/src/components/toolbar/menu/items/index.ts b/src/components/toolbar/menu/items/index.ts index c83f5ee..04121ec 100644 --- a/src/components/toolbar/menu/items/index.ts +++ b/src/components/toolbar/menu/items/index.ts @@ -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'; diff --git a/src/components/toolbar/menu/items/item.module.css b/src/components/toolbar/menu/items/item.module.css new file mode 100644 index 0000000..92d36be --- /dev/null +++ b/src/components/toolbar/menu/items/item.module.css @@ -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; +} \ No newline at end of file diff --git a/src/lib/database.ts b/src/lib/database.ts new file mode 100644 index 0000000..8a5be5c --- /dev/null +++ b/src/lib/database.ts @@ -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 { + 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; +} \ No newline at end of file diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts new file mode 100644 index 0000000..9b57ae1 --- /dev/null +++ b/src/pages/api/auth/login.ts @@ -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' }, + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/register.ts b/src/pages/api/auth/register.ts new file mode 100644 index 0000000..204912d --- /dev/null +++ b/src/pages/api/auth/register.ts @@ -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' }, + }); + } +}; \ No newline at end of file diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..8ce691a --- /dev/null +++ b/src/stores/auth.ts @@ -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; + register: (userData: { username: string; password: string }) => Promise; + logout: () => void; + clearError: () => void; + setLoading: (loading: boolean) => void; + checkAuth: () => Promise; +} + +/** + * 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()( + 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, + }), + } + ) +); \ No newline at end of file