Compare commits

...

10 Commits

Author SHA1 Message Date
ethan.chen
7634c0d1d5 feat: 优化日期选择,字数展示 2025-08-15 14:39:55 +08:00
ethan.chen
e8372dab24 feat: enhance authentication and UI components in App; add Toast for notifications, improve login handling, and update pagination logic 2025-06-23 16:57:23 +08:00
ethan.chen
d2f79cb32e feat: add deployment script for automated project deployment to remote server with backup functionality 2025-06-18 16:40:16 +08:00
ethan.chen
0613966ba4 chore: update package dependencies to include @types/node and undici-types; refactor SHA-256 encryption implementation in App component 2025-06-18 15:39:51 +08:00
ethan.chen
2cd47cf330 feat: add SHA-256 encryption for password handling in login process within App component 2025-06-13 15:21:49 +08:00
ethan.chen
3c346021e3 fix: update API endpoint for user login and adjust authentication logic in App component; modify rating display in MediaItem component 2025-06-13 14:32:34 +08:00
ethan.chen
35e0df26fe feat: enhance UI components with improved styling, transitions, and layout adjustments across App, MediaFormModal, MediaItem, and StarRating components 2025-06-03 17:41:10 +08:00
ethan.chen
701021c112 feat: integrate StarRating component into MediaFormModal for improved rating input 2025-05-27 18:40:23 +08:00
ethan.chen
69e4a66375 feat: update DatePicker to handle date selection via onSelect callback in MediaFormModal 2025-05-27 18:32:12 +08:00
ethan.chen
97243c33c2 feat: add DatePicker component for date selection in MediaFormModal, update dependencies, and refactor date input handling 2025-05-27 18:26:43 +08:00
13 changed files with 562 additions and 142 deletions

55
deploy.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# 服务器配置
SERVER="123.57.93.143"
USER="root"
REMOTE_DIR="/home/media-front"
BACKUP_DIR="$REMOTE_DIR/backup"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# 本地构建
echo "Building project..."
npm run build
# 检查构建是否成功
if [ ! -d "dist" ]; then
echo "Build failed! dist directory not found."
exit 1
fi
# 创建部署包
echo "Creating deployment package..."
tar -czf dist.tar.gz dist/
# 创建远程目录
echo "Creating remote directories..."
ssh $USER@$SERVER "mkdir -p $REMOTE_DIR $BACKUP_DIR"
# 如果远程目录存在部署文件,先备份
echo "Backing up existing deployment..."
ssh $USER@$SERVER "if [ -d $REMOTE_DIR/dist ]; then \
tar -czf $BACKUP_DIR/dist_backup_$TIMESTAMP.tar.gz $REMOTE_DIR/dist && \
rm -rf $REMOTE_DIR/dist; \
fi"
# 上传新的部署包
echo "Uploading new deployment package..."
scp dist.tar.gz $USER@$SERVER:$REMOTE_DIR/
# 解压部署包
echo "Extracting deployment package..."
ssh $USER@$SERVER "cd $REMOTE_DIR && \
tar -xzf dist.tar.gz && \
rm dist.tar.gz"
# 清理本地部署包
echo "Cleaning up local deployment package..."
rm dist.tar.gz
echo "Deployment completed successfully!"
echo "Your application is now deployed to $REMOTE_DIR/dist"
echo "A backup has been created at $BACKUP_DIR/dist_backup_$TIMESTAMP.tar.gz"
# 清理旧的备份保留最近10个
echo "Cleaning up old backups..."
ssh $USER@$SERVER "cd $BACKUP_DIR && ls -t dist_backup_*.tar.gz | tail -n +11 | xargs rm -f 2>/dev/null || true"

134
package-lock.json generated
View File

@@ -8,9 +8,12 @@
"name": "my-score", "name": "my-score",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@melt-ui/svelte": "^0.86.6",
"@skeletonlabs/skeleton": "^2.0.0", "@skeletonlabs/skeleton": "^2.0.0",
"@types/node": "^24.0.3",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"axios": "^1.6.7", "axios": "^1.6.7",
"date-fns": "^4.1.0",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1" "tailwindcss": "^3.4.1"
}, },
@@ -473,6 +476,40 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
"integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
"integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@internationalized/date": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.1.tgz",
"integrity": "sha512-PgVE6B6eIZtzf9Gu5HvJxRK3ufUFz9DhspELuhW/N0GuMGMTLvPQNRkHP2hTuP9lblOk+f+1xi96sPiPXANXAA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -538,6 +575,41 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@melt-ui/svelte": {
"version": "0.86.6",
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.86.6.tgz",
"integrity": "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.3.1",
"@floating-ui/dom": "^1.4.5",
"@internationalized/date": "^3.5.0",
"dequal": "^2.0.3",
"focus-trap": "^7.5.2",
"nanoid": "^5.0.4"
},
"peerDependencies": {
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118"
}
},
"node_modules/@melt-ui/svelte/node_modules/nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -930,6 +1002,15 @@
"vite": "^6.0.0" "vite": "^6.0.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tsconfig/svelte": { "node_modules/@tsconfig/svelte": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.4.tgz",
@@ -943,6 +1024,14 @@
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "24.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
"dependencies": {
"undici-types": "~7.8.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.1", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -1291,6 +1380,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -1328,6 +1427,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1546,6 +1654,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/focus-trap": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz",
"integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==",
"license": "MIT",
"dependencies": {
"tabbable": "^6.2.0"
}
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.9", "version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
@@ -2676,6 +2793,12 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.17", "version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@@ -2829,6 +2952,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -2843,6 +2972,11 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@@ -18,10 +18,13 @@
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"@melt-ui/svelte": "^0.86.6",
"@skeletonlabs/skeleton": "^2.0.0", "@skeletonlabs/skeleton": "^2.0.0",
"tailwindcss": "^3.4.1", "@types/node": "^24.0.3",
"postcss": "^8.4.35",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"axios": "^1.6.7" "axios": "^1.6.7",
"date-fns": "^4.1.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1"
} }
} }

View File

@@ -5,6 +5,7 @@
import MediaItem from './lib/MediaItem.svelte'; import MediaItem from './lib/MediaItem.svelte';
import MediaFormModal from './lib/MediaFormModal.svelte'; import MediaFormModal from './lib/MediaFormModal.svelte';
import DeleteConfirmModal from './lib/DeleteConfirmModal.svelte'; import DeleteConfirmModal from './lib/DeleteConfirmModal.svelte';
import Toast from './lib/Toast.svelte';
// 类型定义 // 类型定义
interface ApiResponse<T> { interface ApiResponse<T> {
@@ -49,11 +50,23 @@
{ id: 'other', label: '其他' } { id: 'other', label: '其他' }
]; ];
// 添加简单的Base64编码函数
function encodePassword(message: string): string {
return btoa(message);
}
// 初始化函数:检查认证状态 // 初始化函数:检查认证状态
async function initializeAuth() { async function initializeAuth() {
const auth = localStorage.getItem('auth'); const authString = localStorage.getItem('auth');
if (auth) { if (authString) {
try { 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'); const response = await request.get<ApiResponse<{username: string}>>('/auth/verify');
@@ -100,32 +113,23 @@
} }
} }
// 监听类别变化
$effect(() => {
if (currentCategory) {
currentPage = 1;
}
});
// 监听分页变化
$effect(() => {
if (currentPage) {
fetchMediaList();
}
});
// 登录处理 // 登录处理
async function handleLogin(e: Event) { async function handleLogin(e: Event) {
e.preventDefault(); e.preventDefault();
try { try {
// 保存认证信息 // 对密码进行简单编码
const auth = btoa(`${username}:${password}`); const encodedPassword = encodePassword(password);
localStorage.setItem('auth', auth);
// 使用验证接口验证登录 // 使用验证接口验证登录
const response = await request.get<ApiResponse<{username: string}>>('/auth/verify'); const response = await request.post<ApiResponse<{token: string}>>('/user/login', {
username,
password: encodedPassword
});
if (response.data.code === 0) { if (response.data.code === 200) {
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; isAuthenticated = true;
error = ''; error = '';
// 获取初始数据 // 获取初始数据
@@ -203,12 +207,30 @@
error = e.message || 'Failed to delete media'; 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> </script>
<svelte:head> <svelte:head>
<title>My Score</title> <title>My Score</title>
</svelte:head> </svelte:head>
<Toast />
{#if isInitializing} {#if isInitializing}
<div class="min-h-screen flex items-center justify-center bg-gray-100"> <div class="min-h-screen flex items-center justify-center bg-gray-100">
<div class="text-center"> <div class="text-center">
@@ -221,35 +243,35 @@
<div class="w-[480px] space-y-8 p-8 bg-white rounded-lg shadow-lg"> <div class="w-[480px] space-y-8 p-8 bg-white rounded-lg shadow-lg">
<div> <div>
<h2 class="text-center text-3xl font-extrabold text-gray-900"> <h2 class="text-center text-3xl font-extrabold text-gray-900">
My Score 我的评分
</h2> </h2>
<p class="mt-2 text-center text-sm text-gray-600"> <p class="mt-2 text-center text-sm text-gray-600">
Sign in to your account 登录你的账号
</p> </p>
</div> </div>
<form class="mt-8 space-y-6" onsubmit={handleLogin}> <form class="mt-8 space-y-6" onsubmit={handleLogin}>
<div class="space-y-4"> <div class="space-y-4">
<div> <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 <input
id="username" id="username"
name="username" name="username"
type="text" type="text"
required 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" 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} bind:value={username}
/> />
</div> </div>
<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 <input
id="password" id="password"
name="password" name="password"
type="password" type="password"
required 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" 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} bind:value={password}
/> />
</div> </div>
@@ -266,31 +288,35 @@
type="submit" 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" 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> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
{:else} {:else}
<div class="min-h-screen bg-gray-100" transition:fade> <div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100" transition:fade>
<div class="min-w-[1200px] mx-auto px-6 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 类别选择栏 --> <!-- 类别选择栏 -->
<div class="bg-white shadow rounded-lg mb-6"> <div class="bg-white/80 backdrop-blur-sm shadow-sm rounded-xl mb-6 border border-gray-200">
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex space-x-6"> <div class="flex space-x-4">
{#each categories as category} {#each categories as category}
<button <button
class="px-4 py-2 rounded-md text-sm font-medium {currentCategory === category.id ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" 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} {category.label}
</button> </button>
{/each} {/each}
</div> </div>
<button <button
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-lg hover:from-indigo-600 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all duration-200 shadow-sm hover:shadow-md"
onclick={() => showCreateModal = true} onclick={() => showCreateModal = true}
> >
添加新作品 添加新作品
@@ -300,38 +326,62 @@
</div> </div>
<!-- 媒体列表 --> <!-- 媒体列表 -->
<div class="bg-white shadow rounded-lg"> <div class="bg-white/80 backdrop-blur-sm shadow-sm rounded-xl border border-gray-200">
<div class="px-6 py-4"> <div class="px-6 py-4">
{#if loading} {#if loading}
<div class="text-center py-8"> <div class="text-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each mediaList as media} {#each mediaList as media}
<MediaItem {media} onEdit={handleEditClick} onDelete={handleDeleteClick} /> <MediaItem {media} onEdit={handleEditClick} onDelete={handleDeleteClick} />
{/each} {/each}
</div> </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-700"> <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>
<div class="flex space-x-2"> <div class="flex items-center space-x-1">
<button <button
class="px-4 py-2 rounded-md text-sm font-medium {currentPage === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50 border'}" 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} disabled={currentPage === 1}
onclick={() => currentPage = currentPage - 1} onclick={() => {
currentPage = currentPage - 1;
fetchMediaList();
}}
> >
Previous 上一页
</button> </button>
{#each pageNumbers as num, i}
{#if num === '...'}
<span class="px-2 text-gray-400 select-none">...</span>
{:else}
<button <button
class="px-4 py-2 rounded-md text-sm font-medium {currentPage * pageSize >= totalItems ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50 border'}" 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={currentPage * pageSize >= totalItems} disabled={typeof num === 'number' ? currentPage === num : false}
onclick={() => currentPage = currentPage + 1} onclick={() => {
if (typeof num === 'number') {
currentPage = num;
fetchMediaList();
}
}}
> >
Next {num}
</button>
{/if}
{/each}
<button
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;
fetchMediaList();
}}
>
下一页
</button> </button>
</div> </div>
</div> </div>

117
src/lib/DatePicker.svelte Normal file
View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, startOfWeek, endOfWeek } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { fade, scale } from 'svelte/transition';
let { value, placeholder = '选择日期', onSelect } = $props();
let isOpen = $state(false);
let currentMonth = $state(value ? new Date(value) : new Date());
let inputElement: HTMLInputElement;
function handleSelect(date: Date) {
const dateStr = format(date, 'yyyy-MM-dd');
onSelect(dateStr);
isOpen = false;
}
function handleClickOutside(event: MouseEvent) {
if (inputElement && !inputElement.contains(event.target as Node)) {
isOpen = false;
}
}
function prevMonth() {
currentMonth = subMonths(currentMonth, 1);
}
function nextMonth() {
currentMonth = addMonths(currentMonth, 1);
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
$effect(() => {
if (value) {
currentMonth = new Date(value);
}
});
</script>
<div class="relative w-full">
<input
bind:this={inputElement}
type="text"
{placeholder}
value={value || ''}
readonly
onclick={() => isOpen = true}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{#if isOpen}
<div
class="absolute z-50 mt-1 bg-white rounded-lg shadow-lg border border-gray-200"
transition:scale={{ duration: 200 }}
>
<div class="p-2">
<div class="flex items-center justify-between mb-2">
<button
class="p-1 hover:bg-gray-100 rounded-full"
onclick={(e) => {
e.stopPropagation();
prevMonth();
}}
aria-label="上一月"
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<span class="text-sm font-medium">
{format(currentMonth, 'yyyy年MM月', { locale: zhCN })}
</span>
<button
class="p-1 hover:bg-gray-100 rounded-full"
onclick={(e) => {
e.stopPropagation();
nextMonth();
}}
aria-label="下一月"
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="grid grid-cols-7 gap-1">
{#each ['日', '一', '二', '三', '四', '五', '六'] as day}
<div class="text-center text-sm text-gray-500 py-1">{day}</div>
{/each}
{#each eachDayOfInterval({
start: startOfWeek(startOfMonth(currentMonth), { weekStartsOn: 0 }),
end: endOfWeek(endOfMonth(currentMonth), { weekStartsOn: 0 })
}) as date}
<button
class:text-gray-400={!isSameMonth(date, currentMonth)}
class:bg-blue-500={value && format(new Date(value), 'yyyy-MM-dd') === format(date, 'yyyy-MM-dd')}
class:text-white={value && format(new Date(value), 'yyyy-MM-dd') === format(date, 'yyyy-MM-dd')}
class:border-2={isToday(date)}
class:border-blue-500={isToday(date)}
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
handleSelect(date);
}}
>
{format(date, 'd')}
</button>
{/each}
</div>
</div>
</div>
{/if}
</div>

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Media } from './interfaces'; import type { Media } from './interfaces';
import { fade, scale } from 'svelte/transition'; import { fade, scale } from 'svelte/transition';
import DatePicker from './DatePicker.svelte';
import StarRating from './StarRating.svelte';
let {show, mode, submitMedia, handleClose, media: initialMedia, itemType} = $props(); let {show, mode, submitMedia, handleClose, media: initialMedia, itemType} = $props();
let media: Media = $state({ let media: Media = $state({
@@ -43,30 +45,28 @@
function handleSubmit(e: Event) { function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
// 暂时留空,后续处理
submitMedia(media);
} }
</script> </script>
{#if show} {#if show}
<div <div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50"
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
onclick={handleClose} onclick={handleClose}
role="presentation" role="presentation"
> >
<div <div
class="bg-white rounded-lg p-6 w-full mx-4 max-w-[800px]" class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-6 w-full mx-4 max-w-[800px] shadow-2xl"
transition:scale={{ duration: 200 }} transition:scale={{ duration: 200 }}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
role="presentation" role="presentation"
> >
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 id="modal-title" class="text-xl font-semibold text-gray-900"> <h2 id="modal-title" class="text-xl font-semibold bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
{mode === 'add' ? '添加新作品' : '编辑作品'} {mode === 'add' ? '添加新作品' : '编辑作品'}
</h2> </h2>
<button <button
class="text-gray-400 hover:text-gray-500" class="text-gray-400 hover:text-gray-600 transition-colors duration-200"
onclick={handleClose} onclick={handleClose}
type="button" type="button"
aria-label="关闭" aria-label="关闭"
@@ -84,7 +84,7 @@
id="title" id="title"
type="text" type="text"
bind:value={media.title} bind:value={media.title}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white/50 backdrop-blur-sm transition-all duration-200"
placeholder="请输入标题" placeholder="请输入标题"
required required
aria-required="true" aria-required="true"
@@ -96,7 +96,7 @@
<select <select
id="type" id="type"
bind:value={media.type} bind:value={media.type}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white/50 backdrop-blur-sm transition-all duration-200"
required required
aria-required="true" aria-required="true"
> >
@@ -108,26 +108,18 @@
<div class="flex justify-between items-center gap-6"> <div class="flex justify-between items-center gap-6">
<label class="font-medium text-gray-700 whitespace-nowrap" for="date">日期</label> <label class="font-medium text-gray-700 whitespace-nowrap" for="date">日期</label>
<input <DatePicker
id="date" value={media.date}
type="date" onSelect={(date: string) => media.date = date}
bind:value={media.date} placeholder="选择日期"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
aria-label="选择日期"
/> />
</div> </div>
<div class="flex justify-between items-center gap-6"> <div class="flex start items-center gap-6">
<label class="font-medium text-gray-700 whitespace-nowrap" for="rating">评分</label> <label for="score" class="font-medium text-gray-700 whitespace-nowrap">评分</label>
<input <StarRating
id="rating" value={media.rating}
type="number" onSelect={(score: number) => media.rating = score}
min="0"
max="10"
step="0.5"
bind:value={media.rating}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
aria-label="输入评分范围0-10"
/> />
</div> </div>
{#if media.type === 'game' || media.type === 'other'} {#if media.type === 'game' || media.type === 'other'}
@@ -139,7 +131,7 @@
id="platform" id="platform"
type="text" type="text"
bind:value={media.platform} bind:value={media.platform}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white/50 backdrop-blur-sm transition-all duration-200"
placeholder="例如Steam、Netflix、Kindle等" placeholder="例如Steam、Netflix、Kindle等"
aria-label="输入平台名称" aria-label="输入平台名称"
/> />
@@ -150,24 +142,26 @@
<textarea <textarea
id="notes" id="notes"
bind:value={media.notes} bind:value={media.notes}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white/50 backdrop-blur-sm transition-all duration-200"
rows="3" rows="10"
placeholder="添加一些备注..." placeholder="添加一些备注..."
aria-label="添加备注信息" aria-label="添加备注信息"
></textarea> ></textarea>
</div> </div>
<div class="text-sm text-left text-gray-500">字数:{media.notes?.length || 0}</div>
<div class="flex justify-end gap-3 mt-6"> <div class="flex justify-end gap-3 mt-6">
<button <button
type="button" type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200"
onclick={handleClose} onclick={handleClose}
> >
取消 取消
</button> </button>
<button <button
type="submit" type="button"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" onclick={() => submitMedia(media)}
class="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-lg hover:from-indigo-600 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all duration-200 shadow-sm hover:shadow-md"
> >
{mode === 'add' ? '添加' : '保存'} {mode === 'add' ? '添加' : '保存'}
</button> </button>

View File

@@ -1,67 +1,59 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition';
import type { Media } from './interfaces'; import type { Media } from './interfaces';
let {media, onEdit, onDelete}: {media: Media, onEdit: (media: Media) => void, onDelete: (media: Media) => void} = $props(); let {media, onEdit, onDelete}: {media: Media, onEdit: (media: Media) => void, onDelete: (media: Media) => void} = $props();
// 状态映射
const statusMap = {
'completed': '已完成',
'in_progress': '进行中',
'plan_to_watch': '计划中'
};
// 星星评分组件 // 星星评分组件
import { StarRating } from './utils'; import { StarRating } from './utils';
</script> </script>
<div class="border rounded-lg p-4 hover:bg-gray-50 relative" transition:fade> <div class="bg-white/90 backdrop-blur-sm rounded-xl shadow-[0_2px_8px_-2px_rgba(0,0,0,0.1)] hover:shadow-[0_4px_12px_-4px_rgba(0,0,0,0.15)] transition-all duration-200 border border-gray-100/50 overflow-hidden">
<div class="p-6">
<div class="flex justify-between items-center mb-1">
<span>
<h2 class="text-lg font-semibold text-gray-800 line-clamp-1">
{media.title}
{#if media.platform}
<span class="px-2 py-1 text-gray-600 text-sm bg-gray-50/80 rounded-full">{`(${media.platform})`}</span>
{/if}
</h2>
</span>
<div class="flex items-center gap-2">
<button <button
class="delete-button absolute top-0 right-0 p-2 text-gray-400/50 hover:text-red-500 hover:bg-red-50 rounded-full transition-all duration-200" onclick={() => onEdit(media)}
onclick={(e) => { class="text-gray-400 hover:text-indigo-400 transition-colors duration-200"
e.stopPropagation(); aria-label="编辑"
onDelete(media); >
}} <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
<button
onclick={() => onDelete(media)}
class="text-gray-400 hover:text-rose-400 transition-colors duration-200"
aria-label="删除" aria-label="删除"
> >
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg> </svg>
</button> </button>
<div class="flex justify-between items-start" role="presentation" onclick={() => onEdit(media)}>
<div class="space-y-2 flex-1">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 truncate max-w-[70%]">
{media.title}
{#if media.platform}
<span class="px-2 py-1 bg-gray-100 rounded text-sm text-gray-700">
平台:{media.platform}
</span>
{/if}
</h3>
<div class="flex flex-wrap gap-2 ml-auto mr-2">
{#if media.rating}
<div class="px-2 py-1 bg-gray-100 rounded text-sm text-gray-700 flex items-center gap-1">
<span>评分:</span>
<div class="flex items-center">
{@html StarRating(media.rating / 2)}
</div>
<span class="ml-1">({media.rating}/10)</span>
</div>
{/if}
</div>
<div class="text-sm text-gray-500">
{media.date ? new Date(media.date).toLocaleDateString() : '未设置日期'}
</div> </div>
</div> </div>
<div class="space-y-2 text-sm text-gray-600 flex justify-between">
<p class="flex items-center gap-2">
<span class="text-gray-500">评分:</span>
<span class="flex items-center gap-1">{@html StarRating((media.rating ?? 0) / 2)}</span>
</p>
<p class="flex items-center gap-2">
<span class="text-gray-500">日期:</span>
<span class="text-gray-700">{media.date}</span>
</p>
</div>
{#if media.notes} {#if media.notes}
<div class="mt-2 text-sm text-gray-600 rounded"> <div class="mt-1 pt-1 border-t border-gray-100/50">
<div class="whitespace-pre-wrap text-left line-clamp-4">{media.notes}</div> <p class="text-sm text-gray-600 line-clamp-3">{media.notes}</p>
</div> </div>
{/if} {/if}
</div> </div>
</div>
</div> </div>
<style> <style>

38
src/lib/StarRating.svelte Normal file
View File

@@ -0,0 +1,38 @@
<script lang="ts">
let { value = 0, maxStars = 10, onSelect } = $props();
let hoverValue = $state(0);
function handleStarClick(index: number) {
onSelect(index + 1);
}
function handleStarHover(index: number) {
hoverValue = index + 1;
}
function handleMouseLeave() {
hoverValue = 0;
}
</script>
<div class="flex items-center gap-1" onmouseleave={handleMouseLeave} role="presentation">
{#each Array(maxStars) as _, i}
<div
class="text-xl focus:outline-none transition-all duration-200 cursor-pointer hover:scale-110"
class:text-yellow-400={i < (hoverValue || value)}
class:text-gray-200={i >= (hoverValue || value)}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
handleStarClick(i);
}}
onmouseenter={() => handleStarHover(i)}
role="presentation"
>
</div>
{/each}
{#if value > 0}
<span class="ml-2 text-sm font-medium bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">{value}</span>
{/if}
</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 * @Date: 2025-05-19 18:10:10
* @LastEditors: 陈子健 * @LastEditors: 陈子健
* @LastEditTime: 2025-05-20 18:14:14 * @LastEditTime: 2025-06-23 14:54:40
* @FilePath: /my-score/frontend/src/lib/request.ts * @FilePath: /my-score/frontend/src/lib/request.ts
*/ */
import axios from 'axios'; import axios from 'axios';
import { showToast } from './toast';
const request = axios.create({ const request = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -18,9 +19,17 @@ const request = axios.create({
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
// 从 localStorage 获取认证信息 // 从 localStorage 获取认证信息
const auth = localStorage.getItem('auth'); const authString = localStorage.getItem('auth');
if (auth) { if (authString) {
config.headers.Authorization = `Basic ${auth}`; 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; return config;
}, },
@@ -36,9 +45,19 @@ request.interceptors.response.use(
}, },
(error) => { (error) => {
if (error.response?.status === 401) { 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'); 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); 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,17 +1,16 @@
/* /*
* @Date: 2025-05-26 17:39:36 * @Date: 2025-05-26 17:39:36
* @LastEditors: 陈子健 * @LastEditors: 陈子健
* @LastEditTime: 2025-05-26 17:39:47 * @LastEditTime: 2025-06-13 14:32:26
* @FilePath: /my-score/frontend/src/lib/utils.ts * @FilePath: /my-score/frontend/src/lib/utils.ts
*/ */
export const StarRating = (rating: number, maxStars: number = 5) => { export const StarRating = (rating: number, maxStars: number = 5) => {
const stars = []; const stars = [];
for (let i = 1; i <= maxStars; i++) { for (let i = 1; i <= maxStars; i++) {
const fill = i <= Math.floor(rating) ? '#FFD700' : 'white'; const fill = i <= Math.floor(rating) ? '#FFD700' : 'white';
const isHalfStar = i - 0.5 <= rating && rating < i; const isHalfStar = i - 0.5 <= rating && rating < i;
if (isHalfStar) { if (isHalfStar) {
console.log('isHalfStar', isHalfStar);
stars.push(` stars.push(`
<div class="relative w-4 h-4"> <div class="relative w-4 h-4">
<svg class="absolute w-4 h-4" viewBox="0 0 24 24" fill="white" stroke="currentColor"> <svg class="absolute w-4 h-4" viewBox="0 0 24 24" fill="white" stroke="currentColor">

View File

@@ -1,7 +1,7 @@
/* /*
* @Date: 2025-05-20 17:59:45 * @Date: 2025-05-20 17:59:45
* @LastEditors: 陈子健 * @LastEditors: 陈子健
* @LastEditTime: 2025-05-20 18:04:34 * @LastEditTime: 2025-08-15 11:20:05
* @FilePath: /my-score/frontend/vite.config.ts * @FilePath: /my-score/frontend/vite.config.ts
*/ */
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@@ -13,7 +13,7 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:5000', target: 'http://review.falsita.cn',
changeOrigin: true, changeOrigin: true,
secure: false secure: false
} }