feat: enhance authentication and UI components in App; add Toast for notifications, improve login handling, and update pagination logic

This commit is contained in:
ethan.chen
2025-06-23 16:57:23 +08:00
parent d2f79cb32e
commit e8372dab24
5 changed files with 121 additions and 41 deletions

View File

@@ -5,6 +5,7 @@
import MediaItem from './lib/MediaItem.svelte';
import MediaFormModal from './lib/MediaFormModal.svelte';
import DeleteConfirmModal from './lib/DeleteConfirmModal.svelte';
import Toast from './lib/Toast.svelte';
// 类型定义
interface ApiResponse<T> {
@@ -56,9 +57,16 @@
// 初始化函数:检查认证状态
async function initializeAuth() {
const auth = localStorage.getItem('auth');
if (auth) {
const authString = localStorage.getItem('auth');
if (authString) {
try {
const authData = JSON.parse(authString);
if (new Date().getTime() > authData.expiry) {
localStorage.removeItem('auth');
isInitializing = false;
return;
}
// 使用新的验证接口检查登录状态
const response = await request.get<ApiResponse<{username: string}>>('/auth/verify');
@@ -105,20 +113,6 @@
}
}
// 监听类别变化
$effect(() => {
if (currentCategory) {
currentPage = 1;
}
});
// 监听分页变化
$effect(() => {
if (currentPage) {
fetchMediaList();
}
});
// 登录处理
async function handleLogin(e: Event) {
e.preventDefault();
@@ -133,7 +127,9 @@
});
if (response.data.code === 200) {
localStorage.setItem('auth', response.data.data.token);
const expiry = new Date().getTime() + 2 * 60 * 60 * 1000; // 2 hours
const authData = { token: response.data.data.token, expiry };
localStorage.setItem('auth', JSON.stringify(authData));
isAuthenticated = true;
error = '';
// 获取初始数据
@@ -211,12 +207,30 @@
error = e.message || 'Failed to delete media';
}
}
// 计算总页数
let totalPages = 1;
let pageNumbers: (number | string)[] = [];
$effect(() => {
totalPages = Math.ceil(totalItems / pageSize);
if (totalPages <= 5) {
pageNumbers = Array.from({length: totalPages}, (_, i) => i + 1);
} else if (currentPage <= 3) {
pageNumbers = [1,2,3,4,'...',totalPages];
} else if (currentPage >= totalPages - 2) {
pageNumbers = [1,'...',totalPages-3,totalPages-2,totalPages-1,totalPages];
} else {
pageNumbers = [1,'...',currentPage-1,currentPage,currentPage+1,'...',totalPages];
}
});
</script>
<svelte:head>
<title>My Score</title>
</svelte:head>
<Toast />
{#if isInitializing}
<div class="min-h-screen flex items-center justify-center bg-gray-100">
<div class="text-center">
@@ -229,35 +243,35 @@
<div class="w-[480px] space-y-8 p-8 bg-white rounded-lg shadow-lg">
<div>
<h2 class="text-center text-3xl font-extrabold text-gray-900">
My Score
我的评分
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Sign in to your account
登录你的账号
</p>
</div>
<form class="mt-8 space-y-6" onsubmit={handleLogin}>
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<label for="username" class="block text-sm font-medium text-gray-700">用户名</label>
<input
id="username"
name="username"
type="text"
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter your username"
placeholder="请输入用户名"
bind:value={username}
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<label for="password" class="block text-sm font-medium text-gray-700">密码</label>
<input
id="password"
name="password"
type="password"
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter your password"
placeholder="请输入密码"
bind:value={password}
/>
</div>
@@ -274,7 +288,7 @@
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign in
登录
</button>
</div>
</form>
@@ -291,7 +305,11 @@
{#each categories as category}
<button
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentCategory === category.id ? 'bg-gradient-to-r from-indigo-500 to-indigo-600 text-white shadow-sm' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
onclick={() => currentCategory = category.id}
onclick={() => {
currentCategory = category.id;
currentPage = 1;
fetchMediaList();
}}
>
{category.label}
</button>
@@ -322,24 +340,48 @@
</div>
<!-- 分页控制 -->
<div class="mt-6 flex justify-between items-center">
<div class="mt-6 flex flex-col md:flex-row md:justify-between md:items-center gap-4 md:gap-0">
<div class="text-sm text-gray-600">
Showing {(currentPage - 1) * pageSize + 1} to {Math.min(currentPage * pageSize, totalItems)} of {totalItems} items
显示第 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalItems)} 条,共 {totalItems}
</div>
<div class="flex space-x-3">
<div class="flex items-center space-x-1">
<button
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentPage === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200 shadow-sm hover:shadow-md'}"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentPage === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200 shadow-sm hover:shadow-md'}"
disabled={currentPage === 1}
onclick={() => currentPage = currentPage - 1}
onclick={() => {
currentPage = currentPage - 1;
fetchMediaList();
}}
>
Previous
上一页
</button>
{#each pageNumbers as num, i}
{#if num === '...'}
<span class="px-2 text-gray-400 select-none">...</span>
{:else}
<button
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentPage === num ? 'bg-indigo-500 text-white shadow-sm' : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200 shadow-sm hover:shadow-md'}"
disabled={typeof num === 'number' ? currentPage === num : false}
onclick={() => {
if (typeof num === 'number') {
currentPage = num;
fetchMediaList();
}
}}
>
{num}
</button>
{/if}
{/each}
<button
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentPage * pageSize >= totalItems ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200 shadow-sm hover:shadow-md'}"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentPage * pageSize >= totalItems ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200 shadow-sm hover:shadow-md'}"
disabled={currentPage * pageSize >= totalItems}
onclick={() => currentPage = currentPage + 1}
onclick={() => {
currentPage = currentPage + 1;
fetchMediaList();
}}
>
Next
下一页
</button>
</div>
</div>

9
src/lib/Toast.svelte Normal file
View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { toast } from './toast';
</script>
{#if $toast.visible}
<div class="fixed top-6 left-1/2 transform -translate-x-1/2 bg-red-500 text-white px-4 py-2 rounded shadow z-50 transition-all">
{ $toast.message }
</div>
{/if}

View File

@@ -1,10 +1,11 @@
/*
* @Date: 2025-05-19 18:10:10
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-20 18:14:14
* @LastEditTime: 2025-06-23 14:54:40
* @FilePath: /my-score/frontend/src/lib/request.ts
*/
import axios from 'axios';
import { showToast } from './toast';
const request = axios.create({
baseURL: '/api',
@@ -18,9 +19,17 @@ const request = axios.create({
request.interceptors.request.use(
(config) => {
// 从 localStorage 获取认证信息
const auth = localStorage.getItem('auth');
if (auth) {
config.headers.Authorization = `Basic ${auth}`;
const authString = localStorage.getItem('auth');
if (authString) {
try {
const auth = JSON.parse(authString);
if (auth && auth.token) {
config.headers.Authorization = `Basic ${auth.token}`;
}
} catch (e) {
console.error('Failed to parse auth from localStorage', e);
localStorage.removeItem('auth');
}
}
return config;
},
@@ -36,9 +45,19 @@ request.interceptors.response.use(
},
(error) => {
if (error.response?.status === 401) {
// 清除认证信息
// For login failures, we don't redirect. The component will handle the error.
if (error.config.url === '/user/login') {
return Promise.reject(error.response?.data || error);
}
// For other 401 errors, it means the token is invalid/expired.
// Clear auth data and show toast, then redirect.
localStorage.removeItem('auth');
return
showToast('登录已过期,请重新登录');
setTimeout(() => {
window.location.href = '/';
}, 1500);
return new Promise(() => {}); // Prevent further promise chain execution
}
return Promise.reject(error.response?.data || error);
}

10
src/lib/toast.ts Normal file
View File

@@ -0,0 +1,10 @@
import { writable } from 'svelte/store';
export const toast = writable<{ message: string; visible: boolean }>({ message: '', visible: false });
export function showToast(message: string, duration = 2000) {
toast.set({ message, visible: true });
setTimeout(() => {
toast.set({ message: '', visible: false });
}, duration);
}

View File

@@ -1,7 +1,7 @@
/*
* @Date: 2025-05-20 17:59:45
* @LastEditors: 陈子健
* @LastEditTime: 2025-06-13 14:07:41
* @LastEditTime: 2025-06-23 14:40:15
* @FilePath: /my-score/frontend/vite.config.ts
*/
import { defineConfig } from 'vite'