feat: enhance authentication and UI components in App; add Toast for notifications, improve login handling, and update pagination logic
This commit is contained in:
110
src/App.svelte
110
src/App.svelte
@@ -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
9
src/lib/Toast.svelte
Normal 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}
|
||||
@@ -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
10
src/lib/toast.ts
Normal 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);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user