feat: v3.0 - 用户系统和收藏功能

核心功能:
- 用户注册/登录系统(用户名+密码)
- 工具收藏功能(一键收藏/取消收藏)
- 收藏分组管理(文件夹)
- 用户中心(个人资料、收藏列表)

数据库变更:
- 新增 users 表(用户信息)
- 新增 folders 表(收藏分组)
- 新增 collections 表(收藏记录)

安全增强:
- Admin 和 User 完全隔离
- 修复14个管理员路由的权限漏洞
- 所有管理功能添加用户类型检查

新增文件:
- templates/auth/register.html - 注册页面
- templates/auth/login.html - 登录页面
- templates/user/profile.html - 用户中心
- templates/user/collections.html - 收藏列表
- create_user_tables.py - 数据库迁移脚本
- USER_SYSTEM_README.md - 用户系统文档
- CHANGELOG_v3.0.md - 版本更新日志

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-02-06 19:19:05 +08:00
parent 34cd05b01c
commit 2067fb1712
11 changed files with 2542 additions and 6 deletions

173
templates/auth/login.html Normal file
View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户登录 - ZJPB - 自己品吧</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Noto+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
"primary": "#0ea5e9",
"primary-dark": "#0284c7",
"background": "#f8fafc",
"surface": "#ffffff",
"input-bg": "#ffffff",
"input-border": "#e2e8f0",
"text-main": "#0f172a",
"text-secondary": "#334155",
"text-muted": "#64748b",
},
fontFamily: {
"display": ["Space Grotesk", "sans-serif"],
"body": ["Noto Sans", "sans-serif"],
},
},
},
}
</script>
<style>
.glass-panel {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.tech-bg-grid {
background-image: radial-gradient(circle at center, rgba(14, 165, 233, 0.04) 0%, transparent 60%),
linear-gradient(rgba(14, 165, 233, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(14, 165, 233, 0.05) 1px, transparent 1px);
background-size: 100% 100%, 40px 40px, 40px 40px;
}
</style>
</head>
<body class="bg-[#f8fafc] font-display text-[#0f172a] min-h-screen flex flex-col items-center justify-center relative overflow-hidden selection:bg-sky-500/20 selection:text-sky-700">
<!-- Ambient Background -->
<div class="absolute inset-0 z-0 tech-bg-grid pointer-events-none"></div>
<div class="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-sky-200/40 rounded-full blur-[120px] pointer-events-none mix-blend-multiply"></div>
<div class="absolute bottom-[-10%] left-[-10%] w-[500px] h-[500px] bg-indigo-100/60 rounded-full blur-[100px] pointer-events-none mix-blend-multiply"></div>
<!-- Main Content Wrapper -->
<div class="w-full max-w-[480px] px-4 z-10 flex flex-col gap-6">
<!-- Back Navigation -->
<a class="group flex items-center gap-2 text-[#64748b] hover:text-[#0f172a] transition-colors w-fit" href="{{ url_for('index') }}">
<div class="flex items-center justify-center w-8 h-8 rounded-full border border-[#e2e8f0] bg-white group-hover:border-sky-500/50 group-hover:bg-sky-500/5 transition-all shadow-sm">
<span class="material-symbols-outlined text-sm">arrow_back</span>
</div>
<span class="text-sm font-medium">返回首页</span>
</a>
<!-- Login Card -->
<div class="glass-panel border border-white rounded-2xl shadow-[0_4px_30px_rgba(0,0,0,0.03)] p-8 md:p-12 relative overflow-hidden ring-1 ring-black/5">
<!-- Decorative Accent -->
<div class="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-sky-500/50 to-transparent"></div>
<!-- Header -->
<div class="flex flex-col gap-2 mb-8">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 rounded-lg bg-sky-500/10 text-sky-500">
<span class="material-symbols-outlined text-2xl">login</span>
</div>
<span class="text-xs font-bold tracking-widest uppercase text-sky-500">User Login</span>
</div>
<h1 class="text-3xl font-black tracking-tight text-[#0f172a] leading-tight">用户登录</h1>
<p class="text-[#64748b] text-base font-normal">输入您的登录凭据以访问您的收藏</p>
</div>
<!-- Error Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 flex items-start gap-3">
<span class="material-symbols-outlined text-red-500 text-sm mt-0.5">error</span>
<p class="text-red-600 text-sm">{{ message }}</p>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Form -->
<form method="POST" action="{{ url_for('user_login') }}" class="flex flex-col gap-5">
<!-- Username Field -->
<div class="space-y-2">
<label class="text-[#334155] text-sm font-semibold leading-normal" for="username">用户名</label>
<div class="relative group">
<input class="form-input flex w-full h-12 pl-11 pr-4 rounded-lg text-[#0f172a] focus:outline-0 focus:ring-2 focus:ring-sky-500/20 border border-[#e2e8f0] bg-white focus:border-sky-500 placeholder:text-slate-400 text-base font-normal leading-normal transition-all shadow-sm"
id="username"
name="username"
placeholder="输入您的用户名"
type="text"
required
autofocus/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-sky-500 transition-colors flex items-center justify-center pointer-events-none">
<span class="material-symbols-outlined text-[20px]">person</span>
</div>
</div>
</div>
<!-- Password Field -->
<div class="space-y-2">
<div class="flex justify-between items-end">
<label class="text-[#334155] text-sm font-semibold leading-normal" for="password">密码</label>
</div>
<div class="flex w-full items-stretch rounded-lg group relative">
<input class="form-input flex w-full h-12 pl-11 pr-11 rounded-lg text-[#0f172a] focus:outline-0 focus:ring-2 focus:ring-sky-500/20 border border-[#e2e8f0] bg-white focus:border-sky-500 placeholder:text-slate-400 text-base font-normal leading-normal transition-all shadow-sm z-10"
id="password"
name="password"
placeholder="输入您的密码"
type="password"
required/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-sky-500 transition-colors flex items-center justify-center pointer-events-none z-20">
<span class="material-symbols-outlined text-[20px]">lock</span>
</div>
<button class="absolute right-0 top-0 h-full px-3 text-slate-400 hover:text-[#334155] transition-colors flex items-center justify-center z-20 focus:outline-none"
type="button"
onclick="togglePassword()">
<span class="material-symbols-outlined text-[20px]" id="toggleIcon">visibility_off</span>
</button>
</div>
</div>
<!-- Login Button -->
<button class="mt-4 flex w-full h-12 cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-sky-500 text-white text-base font-bold leading-normal tracking-wide hover:bg-sky-600 transition-all active:scale-[0.98] shadow-lg shadow-sky-500/25 hover:shadow-sky-500/40"
type="submit">
<span class="truncate">登录</span>
</button>
</form>
<!-- Footer Meta -->
<div class="mt-8 flex flex-col items-center gap-4 border-t border-slate-100 pt-6">
<p class="text-[#64748b] text-sm text-center">
还没有账号?<a href="{{ url_for('user_register') }}" class="text-sky-500 hover:text-sky-600 font-semibold">立即注册</a>
</p>
</div>
</div>
</div>
<script>
function togglePassword() {
const passwordInput = document.getElementById('password');
const toggleIcon = document.getElementById('toggleIcon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.textContent = 'visibility';
} else {
passwordInput.type = 'password';
toggleIcon.textContent = 'visibility_off';
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户注册 - ZJPB - 自己品吧</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Noto+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
"primary": "#0ea5e9",
"primary-dark": "#0284c7",
"background": "#f8fafc",
"surface": "#ffffff",
"input-bg": "#ffffff",
"input-border": "#e2e8f0",
"text-main": "#0f172a",
"text-secondary": "#334155",
"text-muted": "#64748b",
},
fontFamily: {
"display": ["Space Grotesk", "sans-serif"],
"body": ["Noto Sans", "sans-serif"],
},
},
},
}
</script>
<style>
.glass-panel {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.tech-bg-grid {
background-image: radial-gradient(circle at center, rgba(14, 165, 233, 0.04) 0%, transparent 60%),
linear-gradient(rgba(14, 165, 233, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(14, 165, 233, 0.05) 1px, transparent 1px);
background-size: 100% 100%, 40px 40px, 40px 40px;
}
</style>
</head>
<body class="bg-[#f8fafc] font-display text-[#0f172a] min-h-screen flex flex-col items-center justify-center relative overflow-hidden selection:bg-sky-500/20 selection:text-sky-700">
<!-- Ambient Background -->
<div class="absolute inset-0 z-0 tech-bg-grid pointer-events-none"></div>
<div class="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-sky-200/40 rounded-full blur-[120px] pointer-events-none mix-blend-multiply"></div>
<div class="absolute bottom-[-10%] left-[-10%] w-[500px] h-[500px] bg-indigo-100/60 rounded-full blur-[100px] pointer-events-none mix-blend-multiply"></div>
<!-- Main Content Wrapper -->
<div class="w-full max-w-[480px] px-4 z-10 flex flex-col gap-6">
<!-- Back Navigation -->
<a class="group flex items-center gap-2 text-[#64748b] hover:text-[#0f172a] transition-colors w-fit" href="{{ url_for('index') }}">
<div class="flex items-center justify-center w-8 h-8 rounded-full border border-[#e2e8f0] bg-white group-hover:border-sky-500/50 group-hover:bg-sky-500/5 transition-all shadow-sm">
<span class="material-symbols-outlined text-sm">arrow_back</span>
</div>
<span class="text-sm font-medium">返回首页</span>
</a>
<!-- Register Card -->
<div class="glass-panel border border-white rounded-2xl shadow-[0_4px_30px_rgba(0,0,0,0.03)] p-8 md:p-12 relative overflow-hidden ring-1 ring-black/5">
<!-- Decorative Accent -->
<div class="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-sky-500/50 to-transparent"></div>
<!-- Header -->
<div class="flex flex-col gap-2 mb-8">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 rounded-lg bg-sky-500/10 text-sky-500">
<span class="material-symbols-outlined text-2xl">person_add</span>
</div>
<span class="text-xs font-bold tracking-widest uppercase text-sky-500">Create Account</span>
</div>
<h1 class="text-3xl font-black tracking-tight text-[#0f172a] leading-tight">用户注册</h1>
<p class="text-[#64748b] text-base font-normal">创建您的账号开始收藏喜欢的AI工具</p>
</div>
<!-- Error Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 flex items-start gap-3">
<span class="material-symbols-outlined text-red-500 text-sm mt-0.5">error</span>
<p class="text-red-600 text-sm">{{ message }}</p>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Form -->
<form method="POST" action="{{ url_for('user_register') }}" class="flex flex-col gap-5">
<!-- Username Field -->
<div class="space-y-2">
<label class="text-[#334155] text-sm font-semibold leading-normal" for="username">用户名</label>
<div class="relative group">
<input class="form-input flex w-full h-12 pl-11 pr-4 rounded-lg text-[#0f172a] focus:outline-0 focus:ring-2 focus:ring-sky-500/20 border border-[#e2e8f0] bg-white focus:border-sky-500 placeholder:text-slate-400 text-base font-normal leading-normal transition-all shadow-sm"
id="username"
name="username"
placeholder="请输入用户名3个字符以上"
type="text"
required
autofocus/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-sky-500 transition-colors flex items-center justify-center pointer-events-none">
<span class="material-symbols-outlined text-[20px]">person</span>
</div>
</div>
</div>
<!-- Password Field -->
<div class="space-y-2">
<label class="text-[#334155] text-sm font-semibold leading-normal" for="password">密码</label>
<div class="flex w-full items-stretch rounded-lg group relative">
<input class="form-input flex w-full h-12 pl-11 pr-11 rounded-lg text-[#0f172a] focus:outline-0 focus:ring-2 focus:ring-sky-500/20 border border-[#e2e8f0] bg-white focus:border-sky-500 placeholder:text-slate-400 text-base font-normal leading-normal transition-all shadow-sm z-10"
id="password"
name="password"
placeholder="请输入密码6个字符以上"
type="password"
required/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-sky-500 transition-colors flex items-center justify-center pointer-events-none z-20">
<span class="material-symbols-outlined text-[20px]">lock</span>
</div>
<button class="absolute right-0 top-0 h-full px-3 text-slate-400 hover:text-[#334155] transition-colors flex items-center justify-center z-20 focus:outline-none"
type="button"
onclick="togglePassword('password')">
<span class="material-symbols-outlined text-[20px]" id="toggleIcon1">visibility_off</span>
</button>
</div>
</div>
<!-- Confirm Password Field -->
<div class="space-y-2">
<label class="text-[#334155] text-sm font-semibold leading-normal" for="confirm_password">确认密码</label>
<div class="flex w-full items-stretch rounded-lg group relative">
<input class="form-input flex w-full h-12 pl-11 pr-11 rounded-lg text-[#0f172a] focus:outline-0 focus:ring-2 focus:ring-sky-500/20 border border-[#e2e8f0] bg-white focus:border-sky-500 placeholder:text-slate-400 text-base font-normal leading-normal transition-all shadow-sm z-10"
id="confirm_password"
name="confirm_password"
placeholder="请再次输入密码"
type="password"
required/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-sky-500 transition-colors flex items-center justify-center pointer-events-none z-20">
<span class="material-symbols-outlined text-[20px]">lock</span>
</div>
<button class="absolute right-0 top-0 h-full px-3 text-slate-400 hover:text-[#334155] transition-colors flex items-center justify-center z-20 focus:outline-none"
type="button"
onclick="togglePassword('confirm_password')">
<span class="material-symbols-outlined text-[20px]" id="toggleIcon2">visibility_off</span>
</button>
</div>
</div>
<!-- Register Button -->
<button class="mt-4 flex w-full h-12 cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-sky-500 text-white text-base font-bold leading-normal tracking-wide hover:bg-sky-600 transition-all active:scale-[0.98] shadow-lg shadow-sky-500/25 hover:shadow-sky-500/40"
type="submit">
<span class="truncate">注册</span>
</button>
</form>
<!-- Footer Meta -->
<div class="mt-8 flex flex-col items-center gap-4 border-t border-slate-100 pt-6">
<p class="text-[#64748b] text-sm text-center">
已有账号?<a href="{{ url_for('user_login') }}" class="text-sky-500 hover:text-sky-600 font-semibold">立即登录</a>
</p>
</div>
</div>
</div>
<script>
function togglePassword(fieldId) {
const passwordInput = document.getElementById(fieldId);
const toggleIcon = document.getElementById(fieldId === 'password' ? 'toggleIcon1' : 'toggleIcon2');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.textContent = 'visibility';
} else {
passwordInput.type = 'password';
toggleIcon.textContent = 'visibility_off';
}
}
</script>
</body>
</html>

View File

@@ -174,6 +174,76 @@
background: var(--primary-dark);
}
/* 用户菜单 */
.user-menu {
position: relative;
}
.user-btn {
display: flex;
align-items: center;
gap: 8px;
}
.avatar-sm {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.dropdown-arrow {
font-size: 10px;
transition: transform 0.2s;
}
.user-btn:hover .dropdown-arrow {
transform: translateY(1px);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 180px;
padding: 8px;
z-index: 1000;
}
.dropdown-menu a {
display: block;
padding: 10px 12px;
color: var(--text-primary);
text-decoration: none;
border-radius: var(--radius-sm);
font-size: 14px;
transition: background 0.2s;
}
.dropdown-menu a:hover {
background: var(--bg-page);
}
.dropdown-menu hr {
margin: 4px 0;
border: none;
border-top: 1px solid var(--border-color);
}
/* 主内容区 */
.main-content {
max-width: 1280px;
@@ -244,8 +314,30 @@
<span class="search-icon">🔍</span>
<input type="text" name="q" placeholder="搜索 AI 工具..." value="{{ search_query or '' }}">
</form>
<a href="/admin/login" class="btn btn-secondary">登录</a>
<a href="/admin/login" class="btn btn-primary">注册</a>
{% if current_user.is_authenticated and current_user.__class__.__name__ == 'User' %}
<!-- 已登录用户 -->
<div class="user-menu">
<button class="btn btn-secondary user-btn" onclick="toggleUserDropdown(event)">
{% if current_user.avatar %}
<img src="{{ current_user.avatar }}" alt="{{ current_user.username }}" class="avatar-sm">
{% else %}
<div class="avatar-sm avatar-placeholder">{{ current_user.username[0].upper() }}</div>
{% endif %}
<span>{{ current_user.username }}</span>
<span class="dropdown-arrow"></span>
</button>
<div id="userDropdown" class="dropdown-menu" style="display: none;">
<a href="/user/profile">👤 个人中心</a>
<a href="/user/collections">⭐ 我的收藏</a>
<hr>
<a href="#" onclick="logout(event)">🚪 退出登录</a>
</div>
</div>
{% else %}
<!-- 未登录 -->
<a href="/login" class="btn btn-secondary">登录</a>
<a href="/register" class="btn btn-primary">注册</a>
{% endif %}
</div>
</div>
</nav>
@@ -291,6 +383,30 @@
})(window, document, "clarity", "script", "uoa2j40sf0");
</script>
<!-- User Dropdown Script -->
<script>
function toggleUserDropdown(event) {
event.stopPropagation();
const dropdown = document.getElementById('userDropdown');
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
}
function logout(event) {
event.preventDefault();
if (confirm('确定要退出登录吗?')) {
window.location.href = '/logout';
}
}
// 点击外部关闭下拉菜单
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('userDropdown');
if (dropdown && !event.target.closest('.user-menu')) {
dropdown.style.display = 'none';
}
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -727,6 +727,12 @@
<span></span>
</a>
<!-- 收藏按钮 -->
<button type="button" id="collectBtn" class="collect-btn" onclick="toggleCollect()">
<span class="collect-icon" id="collectIcon"></span>
<span class="collect-text" id="collectText">收藏</span>
</button>
<!-- v2.5新增:社媒分享入口 -->
<button type="button" class="share-btn" onclick="openShareModal()">
分享
@@ -1156,6 +1162,45 @@ document.addEventListener('DOMContentLoaded', function() {
</script>
<style>
/* 收藏按钮 */
.collect-btn {
margin-top: 12px;
width: 100%;
padding: 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-white);
color: var(--text-secondary);
font-weight: 600;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
}
.collect-btn:hover {
border-color: var(--primary-blue);
color: var(--primary-blue);
}
.collect-btn.collected {
background: rgba(251, 191, 36, 0.1);
border-color: rgba(251, 191, 36, 0.5);
color: #f59e0b;
}
.collect-btn.collected .collect-icon {
animation: pulse 0.4s ease;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
.share-btn {
margin-top: 12px;
width: 100%;
@@ -1521,6 +1566,96 @@ function shareToplatform() {
navigator.clipboard.writeText(text).catch(() => {});
}
}
// ========== 收藏功能 ==========
const siteCode = '{{ site.code }}';
let isCollected = false;
// 页面加载时检查收藏状态
document.addEventListener('DOMContentLoaded', function() {
checkCollectionStatus();
});
function checkCollectionStatus() {
fetch('/api/auth/status')
.then(r => r.json())
.then(data => {
if (!data.logged_in || data.user_type !== 'user') {
return; // 未登录或非普通用户,不检查收藏状态
}
// 已登录,检查收藏状态
fetch(`/api/collections/status/${siteCode}`)
.then(r => r.json())
.then(data => {
isCollected = data.is_collected;
updateCollectButton();
})
.catch(err => console.error('检查收藏状态失败:', err));
})
.catch(err => console.error('检查登录状态失败:', err));
}
function updateCollectButton() {
const btn = document.getElementById('collectBtn');
const icon = document.getElementById('collectIcon');
const text = document.getElementById('collectText');
if (isCollected) {
btn.classList.add('collected');
text.textContent = '已收藏';
} else {
btn.classList.remove('collected');
text.textContent = '收藏';
}
}
function toggleCollect() {
// 先检查登录状态
fetch('/api/auth/status')
.then(r => r.json())
.then(data => {
if (!data.logged_in) {
// 未登录,跳转到登录页
if (confirm('请先登录后再收藏工具')) {
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
}
return;
}
if (data.user_type !== 'user') {
showMessage('管理员账号无法使用收藏功能', 'error');
return;
}
// 已登录,执行收藏/取消收藏
fetch('/api/collections/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ site_code: siteCode })
})
.then(r => r.json())
.then(data => {
if (data.success) {
isCollected = data.action === 'added';
updateCollectButton();
showMessage(data.message, 'success');
} else {
showMessage(data.message || '操作失败', 'error');
}
})
.catch(err => {
console.error('收藏操作失败:', err);
showMessage('网络请求失败', 'error');
});
})
.catch(err => {
console.error('检查登录状态失败:', err);
showMessage('网络请求失败', 'error');
});
}
</script>
<style>

View File

@@ -0,0 +1,258 @@
{% extends 'base_new.html' %}
{% block title %}我的收藏 - ZJPB{% endblock %}
{% block extra_css %}
<style>
.collections-container {
margin-top: 32px;
}
.collections-header {
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.collections-header h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 16px;
}
/* 文件夹标签 */
.folder-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.folder-tab {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-white);
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.folder-tab:hover {
border-color: var(--primary-blue);
color: var(--primary-blue);
}
.folder-tab.active {
background: var(--primary-blue);
border-color: var(--primary-blue);
color: white;
}
/* 工具网格 */
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.tool-card {
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
text-decoration: none;
color: var(--text-primary);
transition: all 0.2s;
display: block;
}
.tool-card:hover {
border-color: var(--primary-blue);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.tool-header {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.tool-logo {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
object-fit: cover;
flex-shrink: 0;
}
.tool-info h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.tool-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tool-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tool-tag {
padding: 4px 10px;
background: var(--bg-page);
color: var(--text-secondary);
border-radius: 4px;
font-size: 12px;
}
.empty-state {
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 80px 40px;
text-align: center;
color: var(--text-muted);
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
/* 分页 */
.pagination {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 32px;
}
.pagination a,
.pagination span {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-white);
color: var(--text-primary);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.pagination a:hover {
border-color: var(--primary-blue);
color: var(--primary-blue);
}
.pagination .active {
background: var(--primary-blue);
border-color: var(--primary-blue);
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="main-content">
<div class="collections-container">
<!-- 头部 -->
<div class="collections-header">
<h1>我的收藏</h1>
<!-- 文件夹标签 -->
<div class="folder-tabs">
<a href="/user/collections" class="folder-tab {% if not current_folder_id %}active{% endif %}">
📂 全部收藏 ({{ pagination.total }})
</a>
<a href="/user/collections?folder_id=none" class="folder-tab {% if current_folder_id == 'none' %}active{% endif %}">
📄 未分类
</a>
{% for folder in folders %}
<a href="/user/collections?folder_id={{ folder.id }}" class="folder-tab {% if current_folder_id == folder.id|string %}active{% endif %}">
{{ folder.icon }} {{ folder.name }} ({{ folder.count }})
</a>
{% endfor %}
</div>
</div>
<!-- 收藏网格 -->
{% if collections %}
<div class="tools-grid">
{% for collection in collections %}
<a href="/site/{{ collection.site.code }}" class="tool-card">
<div class="tool-header">
{% if collection.site.logo %}
<img src="{{ collection.site.logo }}" alt="{{ collection.site.name }}" class="tool-logo">
{% else %}
<div class="tool-logo" style="background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%);"></div>
{% endif %}
<div class="tool-info">
<h3>{{ collection.site.name }}</h3>
</div>
</div>
<p class="tool-desc">{{ collection.site.short_desc or collection.site.description }}</p>
{% if collection.site.tags %}
<div class="tool-tags">
{% for tag in collection.site.tags[:3] %}
<span class="tool-tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
</a>
{% endfor %}
</div>
<!-- 分页 -->
{% if pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_num }}{% if current_folder_id %}&folder_id={{ current_folder_id }}{% endif %}">上一页</a>
{% endif %}
{% for page_num in pagination.iter_pages() %}
{% if page_num %}
{% if page_num == pagination.page %}
<span class="active">{{ page_num }}</span>
{% else %}
<a href="?page={{ page_num }}{% if current_folder_id %}&folder_id={{ current_folder_id }}{% endif %}">{{ page_num }}</a>
{% endif %}
{% else %}
<span>...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="?page={{ pagination.next_num }}{% if current_folder_id %}&folder_id={{ current_folder_id }}{% endif %}">下一页</a>
{% endif %}
</div>
{% endif %}
{% else %}
<!-- 空状态 -->
<div class="empty-state">
<div class="empty-icon">📦</div>
<h2 style="font-size: 20px; margin-bottom: 8px;">暂无收藏</h2>
<p>去首页发现喜欢的AI工具吧</p>
<a href="/" style="display: inline-block; margin-top: 20px; padding: 10px 20px; background: var(--primary-blue); color: white; border-radius: var(--radius-md); text-decoration: none;">浏览工具</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

245
templates/user/profile.html Normal file
View File

@@ -0,0 +1,245 @@
{% extends 'base_new.html' %}
{% block title %}个人中心 - ZJPB{% endblock %}
{% block extra_css %}
<style>
.profile-container {
display: grid;
grid-template-columns: 280px 1fr;
gap: 32px;
margin-top: 32px;
}
/* 侧边栏 */
.sidebar-card {
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
align-self: flex-start;
position: sticky;
top: 88px;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin: 0 auto 16px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
color: white;
font-size: 32px;
font-weight: 700;
}
.profile-username {
text-align: center;
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
}
.profile-bio {
text-align: center;
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
}
.nav-menu a {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.nav-menu a:hover {
background: var(--bg-page);
}
.nav-menu a.active {
background: rgba(14, 165, 233, 0.1);
color: var(--primary-blue);
}
/* 主内容区 */
.main-card {
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 32px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
padding: 20px;
background: var(--bg-page);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--primary-blue);
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: var(--text-secondary);
}
.recent-section h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
}
.collection-item {
display: flex;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: 12px;
text-decoration: none;
color: var(--text-primary);
transition: all 0.2s;
}
.collection-item:hover {
border-color: var(--primary-blue);
box-shadow: var(--shadow-md);
}
.collection-logo {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
object-fit: cover;
flex-shrink: 0;
}
.collection-info h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.collection-info p {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 16px;
}
@media (max-width: 968px) {
.profile-container {
grid-template-columns: 1fr;
}
.sidebar-card {
position: static;
}
}
</style>
{% endblock %}
{% block content %}
<div class="main-content">
<div class="profile-container">
<!-- 侧边栏 -->
<div class="sidebar-card">
<div class="profile-avatar">
{{ current_user.username[0].upper() }}
</div>
<div class="profile-username">{{ current_user.username }}</div>
<div class="profile-bio">{{ current_user.bio or '这个人很懒,什么都没写' }}</div>
<ul class="nav-menu">
<li><a href="/user/profile" class="active">👤 个人资料</a></li>
<li><a href="/user/collections">⭐ 我的收藏</a></li>
</ul>
</div>
<!-- 主内容区 -->
<div>
<div class="main-card">
<h1 style="font-size: 24px; font-weight: 700; margin-bottom: 24px;">个人中心</h1>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ collections_count }}</div>
<div class="stat-label">收藏数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ folders_count }}</div>
<div class="stat-label">文件夹数</div>
</div>
</div>
<!-- 最近收藏 -->
<div class="recent-section">
<h2>最近收藏</h2>
{% if recent_collections %}
{% for collection in recent_collections %}
<a href="/site/{{ collection.site.code }}" class="collection-item">
{% if collection.site.logo %}
<img src="{{ collection.site.logo }}" alt="{{ collection.site.name }}" class="collection-logo">
{% else %}
<div class="collection-logo" style="background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%);"></div>
{% endif %}
<div class="collection-info">
<h3>{{ collection.site.name }}</h3>
<p>{{ collection.site.short_desc or collection.site.description }}</p>
</div>
</a>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📦</div>
<p>还没有收藏任何工具</p>
<p style="margin-top: 8px;"><a href="/" style="color: var(--primary-blue);">去首页逛逛</a></p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}