Compare commits
11 Commits
2389d71b54
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ad264bae | ||
|
|
7634c0d1d5 | ||
|
|
e8372dab24 | ||
|
|
d2f79cb32e | ||
|
|
0613966ba4 | ||
|
|
2cd47cf330 | ||
|
|
3c346021e3 | ||
|
|
35e0df26fe | ||
|
|
701021c112 | ||
|
|
69e4a66375 | ||
|
|
97243c33c2 |
55
deploy.sh
Executable file
55
deploy.sh
Executable 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
134
package-lock.json
generated
@@ -8,9 +8,12 @@
|
||||
"name": "my-score",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
"@skeletonlabs/skeleton": "^2.0.0",
|
||||
"@types/node": "^24.0.3",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"axios": "^1.6.7",
|
||||
"date-fns": "^4.1.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1"
|
||||
},
|
||||
@@ -473,6 +476,40 @@
|
||||
"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": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -538,6 +575,41 @@
|
||||
"@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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -930,6 +1002,15 @@
|
||||
"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": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.4.tgz",
|
||||
@@ -943,6 +1024,14 @@
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"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": {
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
@@ -1291,6 +1380,16 @@
|
||||
"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": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -1328,6 +1427,15 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -1546,6 +1654,15 @@
|
||||
"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": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
@@ -2676,6 +2793,12 @@
|
||||
"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": {
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
@@ -2829,6 +2952,12 @@
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"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": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
@@ -2843,6 +2972,11 @@
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
"@skeletonlabs/skeleton": "^2.0.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8.4.35",
|
||||
"@types/node": "^24.0.3",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
168
src/App.svelte
168
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> {
|
||||
@@ -49,11 +50,23 @@
|
||||
{ id: 'other', label: '其他' }
|
||||
];
|
||||
|
||||
// 添加简单的Base64编码函数
|
||||
function encodePassword(message: string): string {
|
||||
return btoa(message);
|
||||
}
|
||||
|
||||
// 初始化函数:检查认证状态
|
||||
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');
|
||||
|
||||
@@ -100,32 +113,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 监听类别变化
|
||||
$effect(() => {
|
||||
if (currentCategory) {
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听分页变化
|
||||
$effect(() => {
|
||||
if (currentPage) {
|
||||
fetchMediaList();
|
||||
}
|
||||
});
|
||||
|
||||
// 登录处理
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// 保存认证信息
|
||||
const auth = btoa(`${username}:${password}`);
|
||||
localStorage.setItem('auth', auth);
|
||||
// 对密码进行简单编码
|
||||
const encodedPassword = encodePassword(password);
|
||||
|
||||
// 使用验证接口验证登录
|
||||
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;
|
||||
error = '';
|
||||
// 获取初始数据
|
||||
@@ -203,53 +207,71 @@
|
||||
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="min-h-screen flex items-center justify-center bg-gradient-to-br from-sky-50 via-blue-50 to-cyan-50">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">加载中...</p>
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-sky-500 mx-auto"></div>
|
||||
<p class="mt-4 text-sky-700">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !isAuthenticated}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-100" transition:fade>
|
||||
<div class="w-[480px] space-y-8 p-8 bg-white rounded-lg shadow-lg">
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-sky-50 via-blue-50 to-cyan-50" transition:fade>
|
||||
<div class="w-[480px] space-y-8 p-8 bg-white/90 backdrop-blur-sm rounded-xl shadow-xl border border-sky-100">
|
||||
<div>
|
||||
<h2 class="text-center text-3xl font-extrabold text-gray-900">
|
||||
My Score
|
||||
<h2 class="text-center text-3xl font-extrabold bg-gradient-to-r from-sky-600 to-blue-600 bg-clip-text text-transparent">
|
||||
我的评分
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
<p class="mt-2 text-center text-sm text-sky-600">
|
||||
登录你的账号
|
||||
</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-sky-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"
|
||||
class="mt-1 block w-full px-3 py-2 border border-sky-200 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 sm:text-sm bg-white/50"
|
||||
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-sky-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"
|
||||
class="mt-1 block w-full px-3 py-2 border border-sky-200 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 sm:text-sm bg-white/50"
|
||||
placeholder="请输入密码"
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
@@ -264,33 +286,37 @@
|
||||
<div>
|
||||
<button
|
||||
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-gradient-to-r from-sky-500 to-blue-500 hover:from-sky-600 hover:to-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 transition-all duration-200"
|
||||
>
|
||||
Sign in
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-gray-100" transition:fade>
|
||||
<div class="min-w-[1200px] mx-auto px-6 py-8">
|
||||
<div class="min-h-screen bg-gradient-to-br from-sky-50 via-blue-50 to-cyan-50" transition:fade>
|
||||
<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-sky-200/50">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex space-x-6">
|
||||
<div class="flex space-x-4">
|
||||
{#each categories as category}
|
||||
<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'}"
|
||||
onclick={() => currentCategory = category.id}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentCategory === category.id ? 'bg-gradient-to-r from-sky-500 to-blue-500 text-white shadow-md shadow-sky-200' : 'bg-sky-50 text-sky-700 hover:bg-sky-100 border border-sky-200/50'}"
|
||||
onclick={() => {
|
||||
currentCategory = category.id;
|
||||
currentPage = 1;
|
||||
fetchMediaList();
|
||||
}}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<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-sky-500 to-blue-500 rounded-lg hover:from-sky-600 hover:to-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 transition-all duration-200 shadow-md shadow-sky-200 hover:shadow-lg"
|
||||
onclick={() => showCreateModal = true}
|
||||
>
|
||||
添加新作品
|
||||
@@ -300,38 +326,62 @@
|
||||
</div>
|
||||
|
||||
<!-- 媒体列表 -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="bg-white/80 backdrop-blur-sm shadow-sm rounded-xl border border-sky-200/50">
|
||||
<div class="px-6 py-4">
|
||||
{#if loading}
|
||||
<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-sky-500 mx-auto"></div>
|
||||
</div>
|
||||
{: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}
|
||||
<MediaItem {media} onEdit={handleEditClick} onDelete={handleDeleteClick} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700">
|
||||
Showing {(currentPage - 1) * pageSize + 1} to {Math.min(currentPage * pageSize, totalItems)} of {totalItems} items
|
||||
<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">
|
||||
显示第 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalItems)} 条,共 {totalItems} 条
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex items-center space-x-1">
|
||||
<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-sky-100 text-sky-300 cursor-not-allowed' : 'bg-white text-sky-700 hover:bg-sky-50 border border-sky-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-sky-400 select-none">...</span>
|
||||
{:else}
|
||||
<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'}"
|
||||
disabled={currentPage * pageSize >= totalItems}
|
||||
onclick={() => currentPage = currentPage + 1}
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 {currentPage === num ? 'bg-gradient-to-r from-sky-500 to-blue-500 text-white shadow-md shadow-sky-200' : 'bg-white text-sky-700 hover:bg-sky-50 border border-sky-200 shadow-sm hover:shadow-md'}"
|
||||
disabled={typeof num === 'number' ? currentPage === num : false}
|
||||
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-sky-100 text-sky-300 cursor-not-allowed' : 'bg-white text-sky-700 hover:bg-sky-50 border border-sky-200 shadow-sm hover:shadow-md'}"
|
||||
disabled={currentPage * pageSize >= totalItems}
|
||||
onclick={() => {
|
||||
currentPage = currentPage + 1;
|
||||
fetchMediaList();
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
color: #3b82f6;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -61,7 +61,7 @@ button {
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
@@ -74,7 +74,7 @@ button:focus-visible {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
color: #60a5fa;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
|
||||
117
src/lib/DatePicker.svelte
Normal file
117
src/lib/DatePicker.svelte
Normal 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>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Media } from './interfaces';
|
||||
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 media: Media = $state({
|
||||
@@ -43,30 +45,28 @@
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
// 暂时留空,后续处理
|
||||
submitMedia(media);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={handleClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg p-6 w-full mx-4 max-w-[800px]"
|
||||
class="bg-gradient-to-br from-white via-sky-50/80 to-blue-50/80 backdrop-blur-md rounded-xl p-6 w-full mx-4 max-w-[800px] shadow-2xl border border-sky-100/50"
|
||||
transition:scale={{ duration: 200 }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<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-sky-600 to-blue-600 bg-clip-text text-transparent">
|
||||
{mode === 'add' ? '添加新作品' : '编辑作品'}
|
||||
</h2>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-500"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors duration-200"
|
||||
onclick={handleClose}
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
@@ -79,12 +79,12 @@
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="flex justify-between items-center gap-6">
|
||||
<label class="font-medium text-gray-700 whitespace-nowrap" for="title">标题</label>
|
||||
<label class="font-medium text-sky-700 whitespace-nowrap" for="title">标题</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
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-sky-200 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 bg-white/90 backdrop-blur-sm transition-all duration-200"
|
||||
placeholder="请输入标题"
|
||||
required
|
||||
aria-required="true"
|
||||
@@ -92,11 +92,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-6">
|
||||
<label class="font-medium text-gray-700 whitespace-nowrap" for="type">类别</label>
|
||||
<label class="font-medium text-sky-700 whitespace-nowrap" for="type">类别</label>
|
||||
<select
|
||||
id="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-sky-200 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 bg-white/90 backdrop-blur-sm transition-all duration-200"
|
||||
required
|
||||
aria-required="true"
|
||||
>
|
||||
@@ -107,67 +107,61 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-6">
|
||||
<label class="font-medium text-gray-700 whitespace-nowrap" for="date">日期</label>
|
||||
<input
|
||||
id="date"
|
||||
type="date"
|
||||
bind:value={media.date}
|
||||
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="选择日期"
|
||||
<label class="font-medium text-sky-700 whitespace-nowrap" for="date">日期</label>
|
||||
<DatePicker
|
||||
value={media.date}
|
||||
onSelect={(date: string) => media.date = date}
|
||||
placeholder="选择日期"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-6">
|
||||
<label class="font-medium text-gray-700 whitespace-nowrap" for="rating">评分</label>
|
||||
<input
|
||||
id="rating"
|
||||
type="number"
|
||||
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 class="flex start items-center gap-6">
|
||||
<label for="score" class="font-medium text-sky-700 whitespace-nowrap">评分</label>
|
||||
<StarRating
|
||||
value={media.rating}
|
||||
onSelect={(score: number) => media.rating = score}
|
||||
/>
|
||||
</div>
|
||||
{#if media.type === 'game' || media.type === 'other'}
|
||||
<div class="flex justify-between items-center gap-6">
|
||||
<label class="font-medium text-gray-700 whitespace-nowrap" for="platform">{
|
||||
<label class="font-medium text-sky-700 whitespace-nowrap" for="platform">{
|
||||
media.type === 'game' ? '平台' : '类别'
|
||||
}</label>
|
||||
<input
|
||||
id="platform"
|
||||
type="text"
|
||||
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-sky-200 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 bg-white/90 backdrop-blur-sm transition-all duration-200"
|
||||
placeholder="例如:Steam、Netflix、Kindle等"
|
||||
aria-label="输入平台名称"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between items-center gap-6">
|
||||
<label class="font-medium text-gray-700 whitespace-nowrap" for="notes">备注</label>
|
||||
<label class="font-medium text-sky-700 whitespace-nowrap" for="notes">备注</label>
|
||||
<textarea
|
||||
id="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"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-sky-200 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 bg-white/90 backdrop-blur-sm transition-all duration-200"
|
||||
rows="10"
|
||||
placeholder="添加一些备注..."
|
||||
aria-label="添加备注信息"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="text-sm text-left text-gray-500">字数:{media.notes?.length || 0}</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<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-sky-700 bg-sky-100 rounded-lg hover:bg-sky-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 transition-all duration-200"
|
||||
onclick={handleClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
type="button"
|
||||
onclick={() => submitMedia(media)}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-sky-500 to-blue-500 rounded-lg hover:from-sky-600 hover:to-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 transition-all duration-200 shadow-md shadow-sky-200 hover:shadow-lg"
|
||||
>
|
||||
{mode === 'add' ? '添加' : '保存'}
|
||||
</button>
|
||||
|
||||
@@ -1,67 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { Media } from './interfaces';
|
||||
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';
|
||||
</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(59,130,246,0.1)] hover:shadow-[0_4px_12px_-4px_rgba(59,130,246,0.2)] transition-all duration-200 border border-sky-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
|
||||
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={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(media);
|
||||
}}
|
||||
onclick={() => onEdit(media)}
|
||||
class="text-gray-400 hover:text-sky-500 transition-colors duration-200"
|
||||
aria-label="编辑"
|
||||
>
|
||||
<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="删除"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</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 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}
|
||||
<div class="mt-2 text-sm text-gray-600 rounded">
|
||||
<div class="whitespace-pre-wrap text-left line-clamp-4">{media.notes}</div>
|
||||
<div class="mt-1 pt-1 border-t border-gray-100/50">
|
||||
<p class="text-sm text-gray-600 line-clamp-3">{media.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
38
src/lib/StarRating.svelte
Normal file
38
src/lib/StarRating.svelte
Normal 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
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,17 +1,16 @@
|
||||
/*
|
||||
* @Date: 2025-05-26 17:39:36
|
||||
* @LastEditors: 陈子健
|
||||
* @LastEditTime: 2025-05-26 17:39:47
|
||||
* @LastEditTime: 2025-06-13 14:32:26
|
||||
* @FilePath: /my-score/frontend/src/lib/utils.ts
|
||||
*/
|
||||
export const StarRating = (rating: number, maxStars: number = 5) => {
|
||||
const stars = [];
|
||||
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
const fill = i <= Math.floor(rating) ? '#FFD700' : 'white';
|
||||
const isHalfStar = i - 0.5 <= rating && rating < i;
|
||||
|
||||
if (isHalfStar) {
|
||||
console.log('isHalfStar', isHalfStar);
|
||||
stars.push(`
|
||||
<div class="relative w-4 h-4">
|
||||
<svg class="absolute w-4 h-4" viewBox="0 0 24 24" fill="white" stroke="currentColor">
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<div class="min-h-screen bg-gradient-to-br from-sky-50 via-blue-50 to-cyan-50">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -1,8 +1,23 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
"bright-blue": {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* @Date: 2025-05-20 17:59:45
|
||||
* @LastEditors: 陈子健
|
||||
* @LastEditTime: 2025-05-20 18:04:34
|
||||
* @LastEditTime: 2025-08-15 11:20:05
|
||||
* @FilePath: /my-score/frontend/vite.config.ts
|
||||
*/
|
||||
import { defineConfig } from 'vite'
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:5000',
|
||||
target: 'http://review.falsita.cn',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user