mirror of
https://github.com/remvze/moodist.git
synced 2025-12-18 09:24:14 +00:00
feat: 完整认证系统与UI优化 v2.5.0
主要功能: ✨ 完整的用户认证系统 - 用户注册与登录功能 (SQLite + bcrypt) - JWT会话管理与持久化 - 用户状态实时显示 🎨 UI/UX 重大改进 - 垂直布局右上角控制面板 - 顶部通知提示系统 (3秒自动关闭) - 响应式设计与暗色主题优化 - 用户下拉菜单 (点击外部关闭) 🔧 技术优化 - 修复JSON解析错误与ES模块问题 - 清理重复组件,统一LanguageSwitcher - API错误处理改进 - z-index层级优化 🌐 国际化支持 - 中英文双语界面完善 - 通知消息本地化 数据库: SQLite (用户表) 认证: bcrypt 密码加密 前端: React + TypeScript + CSS Modules 后端: Astro API Routes
This commit is contained in:
parent
27bf07e39f
commit
f00263d18c
8 changed files with 801 additions and 64 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "moodist",
|
"name": "moodist",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,15 @@ const count = soundCount();
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
background: linear-gradient(var(--color-neutral-50), transparent);
|
background: linear-gradient(var(--bg-secondary), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .paragraph {
|
& .paragraph {
|
||||||
padding: 30px 0;
|
padding: 30px 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
transparent,
|
transparent,
|
||||||
var(--color-neutral-50) 10%,
|
var(--bg-secondary) 10%,
|
||||||
var(--color-neutral-50) 90%,
|
var(--bg-secondary) 90%,
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -49,8 +49,8 @@ const count = soundCount();
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
font-size: var(--font-xsm);
|
font-size: var(--font-xsm);
|
||||||
color: var(--color-foreground-subtle);
|
color: var(--color-foreground-subtle);
|
||||||
background: linear-gradient(var(--color-neutral-100), transparent);
|
background: linear-gradient(var(--bg-secondary), transparent);
|
||||||
border: 1px solid var(--color-neutral-300);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 20px 20px 20px 8px;
|
border-radius: 20px 20px 20px 8px;
|
||||||
|
|
||||||
& span {
|
& span {
|
||||||
|
|
@ -84,7 +84,7 @@ const count = soundCount();
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--color-neutral-200);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
|
@ -99,7 +99,7 @@ const count = soundCount();
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent,
|
transparent,
|
||||||
var(--color-neutral-300),
|
var(--color-muted),
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
@ -107,11 +107,11 @@ const count = soundCount();
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
background-color: var(--color-neutral-100);
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-neutral-400);
|
outline: 2px solid var(--color-muted);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ import { SharedModal } from '@/components/modals/shared';
|
||||||
import { Toolbar } from '@/components/toolbar';
|
import { Toolbar } from '@/components/toolbar';
|
||||||
import { SnackbarProvider } from '@/contexts/snackbar';
|
import { SnackbarProvider } from '@/contexts/snackbar';
|
||||||
import { MediaControls } from '@/components/media-controls';
|
import { MediaControls } from '@/components/media-controls';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
|
||||||
import { AuthButton } from '@/components/auth-button';
|
|
||||||
|
|
||||||
import { sounds } from '@/data/sounds';
|
import { sounds } from '@/data/sounds';
|
||||||
import { FADE_OUT } from '@/constants/events';
|
import { FADE_OUT } from '@/constants/events';
|
||||||
|
|
@ -96,8 +94,6 @@ export function App() {
|
||||||
return (
|
return (
|
||||||
<SnackbarProvider>
|
<SnackbarProvider>
|
||||||
<StoreConsumer>
|
<StoreConsumer>
|
||||||
<AuthButton />
|
|
||||||
<ThemeToggle />
|
|
||||||
<MediaControls />
|
<MediaControls />
|
||||||
<Container>
|
<Container>
|
||||||
<div id="app" />
|
<div id="app" />
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,463 @@
|
||||||
|
/* 头部控制容器 */
|
||||||
|
.headerControls {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用控制按钮样式 */
|
||||||
|
.controlButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
position: relative;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton:focus {
|
||||||
|
outline: 2px solid var(--color-foreground);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton: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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语言切换器样式 */
|
||||||
.languageSwitcher {
|
.languageSwitcher {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: flex-start;
|
||||||
padding: 6px 12px;
|
gap: 12px;
|
||||||
border: 1px solid var(--color-neutral-200);
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border: none;
|
||||||
background-color: var(--color-neutral-50);
|
border-radius: 6px;
|
||||||
|
background-color: transparent;
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-size: var(--font-xsm);
|
font-size: 14px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageSwitcher:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
color: var(--color-foreground-subtle);
|
color: var(--color-foreground-subtle);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select {
|
.select {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-size: var(--font-xsm);
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 2px;
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
min-width: 80px;
|
min-width: 70px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:hover {
|
.select:hover {
|
||||||
background-color: var(--color-neutral-100);
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:focus-visible {
|
.select:focus-visible {
|
||||||
outline: 2px solid var(--color-neutral-400);
|
outline: 2px solid var(--color-muted);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.languageSwitcher:hover {
|
/* 认证表单样式 */
|
||||||
background-color: var(--color-neutral-100);
|
.authFormOverlay {
|
||||||
border-color: var(--color-neutral-300);
|
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: 70px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) .headerControls {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .controlButton {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .controlButton:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .userIndicator {
|
||||||
|
border-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
: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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示通知样式 */
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1002;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: notificationSlideIn 0.3s ease-out;
|
||||||
|
max-width: 400px;
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationMessage {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationClose:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 成功通知样式 */
|
||||||
|
.notification.success {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误通知样式 */
|
||||||
|
.notification.error {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通知动画 */
|
||||||
|
@keyframes notificationSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(-50%) translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.headerControls {
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 36px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageSwitcher {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMenu {
|
||||||
|
top: 60px;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authFormOverlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
top: 15px;
|
||||||
|
max-width: calc(100vw - 30px);
|
||||||
|
width: calc(100vw - 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationContent {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationMessage {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { FaGlobe } from 'react-icons/fa/index';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FaGlobe, FaSun, FaMoon, FaUser } from 'react-icons/fa/index';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
import styles from './language-switcher.module.css';
|
import styles from './language-switcher.module.css';
|
||||||
|
import { fade } from '@/lib/motion';
|
||||||
|
|
||||||
interface LanguageSwitcherProps {
|
interface LanguageSwitcherProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -9,23 +13,323 @@ interface LanguageSwitcherProps {
|
||||||
|
|
||||||
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
||||||
const { currentLang, changeLanguage, t } = useTranslation();
|
const { currentLang, changeLanguage, t } = useTranslation();
|
||||||
|
const { isAuthenticated, user, login, register, logout, isLoading, checkAuth, error, clearError } = useAuthStore();
|
||||||
|
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(false);
|
||||||
|
const [showAuthForm, setShowAuthForm] = useState(false);
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [showNotification, setShowNotification] = useState(false);
|
||||||
|
const [notificationMessage, setNotificationMessage] = useState('');
|
||||||
|
const [notificationType, setNotificationType] = useState<'success' | 'error'>('success');
|
||||||
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 认证状态检查
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 点击外部关闭用户菜单
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (showUserMenu && !target.closest(`.${styles.headerControls}`) && !target.closest(`.${styles.userMenu}`)) {
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showUserMenu) {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showUserMenu]);
|
||||||
|
|
||||||
|
// 主题切换逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const initialDarkTheme = savedTheme ? savedTheme === 'dark' : systemPrefersDark;
|
||||||
|
setIsDarkTheme(initialDarkTheme);
|
||||||
|
applyTheme(initialDarkTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyTheme = (isDark: boolean) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
root.classList.add('dark-theme');
|
||||||
|
root.style.setProperty('--bg-primary', '#0d1117');
|
||||||
|
root.style.setProperty('--bg-secondary', '#161b22');
|
||||||
|
root.style.setProperty('--bg-tertiary', '#21262d');
|
||||||
|
root.style.setProperty('--bg-quaternary', '#30363d');
|
||||||
|
root.style.setProperty('--color-foreground', '#f0f6fc');
|
||||||
|
root.style.setProperty('--color-foreground-subtle', '#8b949e');
|
||||||
|
root.style.setProperty('--color-foreground-subtler', '#6e7681');
|
||||||
|
root.style.setProperty('--color-muted', '#484f58');
|
||||||
|
root.style.setProperty('--color-border', '#30363d');
|
||||||
|
root.style.setProperty('--component-bg', '#161b22');
|
||||||
|
root.style.setProperty('--component-hover', '#21262d');
|
||||||
|
root.style.setProperty('--component-active', '#30363d');
|
||||||
|
root.style.setProperty('--modal-bg', '#0d1117');
|
||||||
|
root.style.setProperty('--input-bg', '#0d1117');
|
||||||
|
body.style.backgroundColor = '#0d1117';
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark-theme');
|
||||||
|
root.style.setProperty('--bg-primary', '#ffffff');
|
||||||
|
root.style.setProperty('--bg-secondary', '#f8fafc');
|
||||||
|
root.style.setProperty('--bg-tertiary', '#f1f5f9');
|
||||||
|
root.style.setProperty('--bg-quaternary', '#e2e8f0');
|
||||||
|
root.style.setProperty('--color-foreground', '#1e293b');
|
||||||
|
root.style.setProperty('--color-foreground-subtle', '#475569');
|
||||||
|
root.style.setProperty('--color-foreground-subtler', '#64748b');
|
||||||
|
root.style.setProperty('--color-muted', '#94a3b8');
|
||||||
|
root.style.setProperty('--color-border', '#cbd5e1');
|
||||||
|
root.style.setProperty('--component-bg', '#ffffff');
|
||||||
|
root.style.setProperty('--component-hover', '#f8fafc');
|
||||||
|
root.style.setProperty('--component-active', '#f1f5f9');
|
||||||
|
root.style.setProperty('--modal-bg', '#ffffff');
|
||||||
|
root.style.setProperty('--input-bg', '#ffffff');
|
||||||
|
body.style.backgroundColor = '#ffffff';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = !isDarkTheme;
|
||||||
|
setIsDarkTheme(newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示提示信息
|
||||||
|
const showNotificationMessage = (message: string, type: 'success' | 'error') => {
|
||||||
|
setNotificationMessage(message);
|
||||||
|
setNotificationType(type);
|
||||||
|
setShowNotification(true);
|
||||||
|
|
||||||
|
// 3秒后自动关闭
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowNotification(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 认证逻辑
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError(); // 清除之前的错误
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLogin) {
|
||||||
|
await login(formData);
|
||||||
|
showNotificationMessage('登录成功!', 'success');
|
||||||
|
setShowAuthForm(false);
|
||||||
|
setFormData({ username: '', password: '' });
|
||||||
|
} else {
|
||||||
|
await register(formData);
|
||||||
|
showNotificationMessage('注册成功!', 'success');
|
||||||
|
setShowAuthForm(false);
|
||||||
|
setFormData({ username: '', password: '' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '认证失败';
|
||||||
|
showNotificationMessage(errorMessage, 'error');
|
||||||
|
console.error('认证失败:', error);
|
||||||
|
// 认证失败时不关闭弹窗,让用户重新尝试
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthClick = () => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
setShowUserMenu(!showUserMenu);
|
||||||
|
} else {
|
||||||
|
setShowAuthForm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLanguageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleLanguageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
changeLanguage(e.target.value);
|
changeLanguage(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const variants = fade();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.languageSwitcher} ${className || ''}`}>
|
<>
|
||||||
<FaGlobe className={styles.icon} />
|
<div className={`${styles.headerControls} ${className || ''}`}>
|
||||||
<select
|
{/* 语言切换器 */}
|
||||||
value={currentLang}
|
<div className={styles.languageSwitcher}>
|
||||||
onChange={handleLanguageChange}
|
<FaGlobe className={styles.icon} />
|
||||||
className={styles.select}
|
<select
|
||||||
aria-label={t('app.language') || 'Select language'}
|
value={currentLang}
|
||||||
>
|
onChange={handleLanguageChange}
|
||||||
<option value="en">English</option>
|
className={styles.select}
|
||||||
<option value="zh-CN">中文</option>
|
aria-label={t('app.language') || 'Select language'}
|
||||||
</select>
|
>
|
||||||
</div>
|
<option value="en">EN</option>
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主题切换按钮 */}
|
||||||
|
<button
|
||||||
|
className={styles.controlButton}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={isDarkTheme ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
animate="show"
|
||||||
|
aria-hidden="true"
|
||||||
|
exit="hidden"
|
||||||
|
initial="hidden"
|
||||||
|
key={isDarkTheme ? 'moon' : 'sun'}
|
||||||
|
variants={variants}
|
||||||
|
style={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{isDarkTheme ? <FaMoon /> : <FaSun />}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
<span style={{ marginLeft: '8px', fontSize: '14px' }}>
|
||||||
|
{isDarkTheme ? '黑暗' : '明亮'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<motion.button
|
||||||
|
className={styles.controlButton}
|
||||||
|
onClick={handleAuthClick}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
aria-label={isAuthenticated ? `用户: ${user?.username}` : '登录'}
|
||||||
|
>
|
||||||
|
<FaUser />
|
||||||
|
{isAuthenticated && user && (
|
||||||
|
<span className={styles.userIndicator}></span>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
maxWidth: '80px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{isAuthenticated ? (user?.username || '用户') : '登录'}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 认证表单模态框 */}
|
||||||
|
{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 && showUserMenu && (
|
||||||
|
<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();
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
className={styles.logoutButton}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提示通知 */}
|
||||||
|
{showNotification && (
|
||||||
|
<div className={`${styles.notification} ${styles[notificationType]}`}>
|
||||||
|
<div className={styles.notificationContent}>
|
||||||
|
<span className={styles.notificationMessage}>
|
||||||
|
{notificationMessage}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={styles.notificationClose}
|
||||||
|
onClick={() => setShowNotification(false)}
|
||||||
|
aria-label="关闭通知"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -54,28 +54,12 @@ const lang = ['en', 'zh-CN'].includes(langParam) ? langParam : 'en';
|
||||||
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<LanguageSwitcher client:load className="language-switcher-fixed" />
|
<LanguageSwitcher client:load className="" />
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<Reload client:load />
|
<Reload client:load />
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.language-switcher-fixed {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 1000;
|
|
||||||
background: rgba(24, 24, 27, 0.8);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.language-switcher-fixed {
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,16 @@ import { authenticateUser } from '@/lib/database';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = await request.json();
|
const body = await request.text();
|
||||||
|
|
||||||
|
if (!body.trim()) {
|
||||||
|
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = JSON.parse(body);
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
|
|
@ -38,7 +47,15 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登录错误:', error);
|
console.error('登录错误:', error);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: '登录失败,请稍后再试' }), {
|
let errorMessage = '登录失败,请稍后再试';
|
||||||
|
|
||||||
|
if (error instanceof SyntaxError && error.message.includes('JSON')) {
|
||||||
|
errorMessage = '请求格式错误';
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,16 @@ import { createUser } from '@/lib/database';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = await request.json();
|
const body = await request.text();
|
||||||
|
|
||||||
|
if (!body.trim()) {
|
||||||
|
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = JSON.parse(body);
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
|
|
@ -45,14 +54,21 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('注册错误:', error);
|
console.error('注册错误:', error);
|
||||||
|
|
||||||
if (error instanceof Error && error.message === '用户名已存在') {
|
let errorMessage = '注册失败,请稍后再试';
|
||||||
return new Response(JSON.stringify({ error: '用户名已存在' }), {
|
|
||||||
|
if (error instanceof SyntaxError && error.message.includes('JSON')) {
|
||||||
|
errorMessage = '请求格式错误';
|
||||||
|
} else if (error instanceof Error && error.message === '用户名已存在') {
|
||||||
|
errorMessage = '用户名已存在';
|
||||||
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
status: 409,
|
status: 409,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: '注册失败,请稍后再试' }), {
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue