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:
329
CHANGELOG_v3.0.md
Normal file
329
CHANGELOG_v3.0.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# ZJPB v3.0 版本更新日志
|
||||
|
||||
**发布日期:** 2025-02-06
|
||||
**版本代号:** User System
|
||||
**重要程度:** 🔴 重大更新
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
### 1. 用户系统 (User System)
|
||||
|
||||
#### ✨ 用户注册与登录
|
||||
- 用户注册功能(用户名 + 密码)
|
||||
- 用户登录/登出
|
||||
- 多用户类型支持(Admin 和 User 分离)
|
||||
- 会话管理和状态持久化
|
||||
|
||||
#### ⭐ 工具收藏功能
|
||||
- 一键收藏/取消收藏工具
|
||||
- 收藏状态实时同步
|
||||
- 收藏按钮视觉反馈(未收藏/已收藏状态)
|
||||
- 未登录用户引导登录
|
||||
|
||||
#### 📁 收藏分组管理
|
||||
- 创建自定义文件夹
|
||||
- 文件夹增删改查
|
||||
- 收藏移动到不同文件夹
|
||||
- 文件夹图标自定义
|
||||
|
||||
#### 👤 用户中心
|
||||
- 个人资料展示
|
||||
- 收藏统计(收藏数、文件夹数)
|
||||
- 最近收藏列表
|
||||
- 收藏列表页面(网格展示 + 分页)
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 数据库变更
|
||||
|
||||
### 新增表
|
||||
|
||||
**users(用户表)**
|
||||
```sql
|
||||
- id: 主键
|
||||
- username: 用户名(唯一,索引)
|
||||
- password_hash: 密码哈希
|
||||
- email: 邮箱(可选,唯一,索引)
|
||||
- avatar: 头像URL
|
||||
- bio: 个人简介
|
||||
- is_active: 是否启用
|
||||
- is_public_profile: 是否公开资料
|
||||
- created_at: 创建时间
|
||||
- last_login: 最后登录时间
|
||||
```
|
||||
|
||||
**folders(收藏分组表)**
|
||||
```sql
|
||||
- id: 主键
|
||||
- user_id: 用户ID(外键,索引)
|
||||
- name: 文件夹名称
|
||||
- description: 描述
|
||||
- icon: 图标emoji
|
||||
- sort_order: 排序权重
|
||||
- is_public: 是否公开
|
||||
- public_slug: 公开链接标识(唯一,索引)
|
||||
- created_at: 创建时间
|
||||
- 唯一约束: (user_id, name)
|
||||
```
|
||||
|
||||
**collections(收藏记录表)**
|
||||
```sql
|
||||
- id: 主键
|
||||
- user_id: 用户ID(外键,索引)
|
||||
- site_id: 网站ID(外键,索引)
|
||||
- folder_id: 文件夹ID(外键,索引,可选)
|
||||
- note: 备注
|
||||
- created_at: 创建时间
|
||||
- updated_at: 更新时间
|
||||
- 唯一约束: (user_id, site_id, folder_id)
|
||||
- 复合索引: (user_id, folder_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 端点
|
||||
|
||||
### 认证 API
|
||||
|
||||
```
|
||||
POST /register - 用户注册
|
||||
POST /login - 用户登录
|
||||
GET /logout - 用户登出
|
||||
GET /api/auth/status - 获取登录状态
|
||||
```
|
||||
|
||||
### 收藏 API(需登录)
|
||||
|
||||
```
|
||||
POST /api/collections/toggle - 收藏/取消收藏
|
||||
GET /api/collections/status/:code - 查询收藏状态
|
||||
GET /api/collections/list - 获取收藏列表
|
||||
PUT /api/collections/:id/note - 更新收藏备注
|
||||
PUT /api/collections/:id/move - 移动收藏
|
||||
```
|
||||
|
||||
### 文件夹 API(需登录)
|
||||
|
||||
```
|
||||
GET /api/folders - 获取文件夹列表
|
||||
POST /api/folders - 创建文件夹
|
||||
PUT /api/folders/:id - 更新文件夹
|
||||
DELETE /api/folders/:id - 删除文件夹
|
||||
```
|
||||
|
||||
### 用户中心
|
||||
|
||||
```
|
||||
GET /user/profile - 用户中心主页
|
||||
GET /user/collections - 收藏列表页面
|
||||
PUT /api/user/profile - 更新用户资料
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 前端更新
|
||||
|
||||
### 新增页面
|
||||
|
||||
1. **用户注册页面** (`/register`)
|
||||
- Tailwind CSS 设计
|
||||
- 表单验证
|
||||
- 密码可见性切换
|
||||
|
||||
2. **用户登录页面** (`/login`)
|
||||
- 与管理员登录页面风格一致
|
||||
- 支持 next 参数跳转
|
||||
|
||||
3. **用户中心** (`/user/profile`)
|
||||
- 侧边栏导航
|
||||
- 统计卡片
|
||||
- 最近收藏展示
|
||||
|
||||
4. **收藏列表** (`/user/collections`)
|
||||
- 文件夹标签切换
|
||||
- 工具网格展示
|
||||
- 分页支持
|
||||
- 空状态提示
|
||||
|
||||
### 界面优化
|
||||
|
||||
- **导航栏**:登录后显示用户头像和下拉菜单
|
||||
- **工具详情页**:添加收藏按钮(带动画效果)
|
||||
- **响应式设计**:所有新页面支持移动端
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全增强
|
||||
|
||||
### 权限隔离
|
||||
|
||||
- ✅ Admin 和 User 完全分离
|
||||
- ✅ 普通用户无法访问管理后台
|
||||
- ✅ 管理员无法使用收藏功能
|
||||
- ✅ 所有管理员 API 添加权限检查
|
||||
|
||||
### 受保护的路由(共14个)
|
||||
|
||||
**Flask-Admin 后台:**
|
||||
- SecureModelView(网站、标签、新闻等管理)
|
||||
- SecureAdminIndexView(控制台首页)
|
||||
|
||||
**管理员页面:**
|
||||
- /admin/change-password
|
||||
- /admin/seo-tools
|
||||
- /admin/batch-import
|
||||
|
||||
**管理员 API:**
|
||||
- /api/fetch-website-info
|
||||
- /api/upload-logo
|
||||
- /api/generate-features
|
||||
- /api/generate-description
|
||||
- /api/generate-tags
|
||||
- /api/fetch-site-news
|
||||
- /api/fetch-all-news
|
||||
- /api/generate-static-sitemap
|
||||
- /api/notify-search-engines
|
||||
|
||||
### 密码安全
|
||||
|
||||
- Werkzeug 密码哈希
|
||||
- 最短密码长度:6位
|
||||
- 最短用户名长度:3位
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件变更
|
||||
|
||||
### 新增文件(7个)
|
||||
|
||||
```
|
||||
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 - 本更新日志
|
||||
```
|
||||
|
||||
### 修改文件(4个)
|
||||
|
||||
```
|
||||
models.py - 新增 User、Folder、Collection 模型
|
||||
app.py - 新增路由和 API,权限检查
|
||||
templates/base_new.html - 导航栏用户菜单
|
||||
templates/detail_new.html - 收藏按钮
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署指南
|
||||
|
||||
### 1. 数据库迁移
|
||||
|
||||
```bash
|
||||
python create_user_tables.py
|
||||
```
|
||||
|
||||
### 2. 启动应用
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
### 3. 测试流程
|
||||
|
||||
1. 访问 `/register` 注册新用户
|
||||
2. 登录后访问工具详情页
|
||||
3. 点击"收藏"按钮测试
|
||||
4. 访问 `/user/collections` 查看收藏
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 破坏性变更
|
||||
|
||||
### Admin 模型变更
|
||||
|
||||
- `Admin.get_id()` 现在返回 `"admin:{id}"` 格式
|
||||
- 旧的管理员账号需要重新登录一次
|
||||
|
||||
### 权限系统
|
||||
|
||||
- 所有管理员路由现在强制检查用户类型
|
||||
- 普通用户无法访问任何管理功能
|
||||
|
||||
---
|
||||
|
||||
## 🔄 兼容性
|
||||
|
||||
- ✅ 向后兼容 v2.x 的所有功能
|
||||
- ✅ 现有管理员账号无需迁移
|
||||
- ✅ 现有网站数据完全兼容
|
||||
- ⚠️ 管理员需要重新登录一次
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
- 收藏列表查询使用 JOIN 避免 N+1 问题
|
||||
- 添加必要的数据库索引
|
||||
- 分页支持大量收藏数据
|
||||
|
||||
---
|
||||
|
||||
## 🎯 后续计划(v3.1+)
|
||||
|
||||
### 待实现功能
|
||||
|
||||
- [ ] 收藏备注编辑 UI
|
||||
- [ ] 文件夹拖拽排序
|
||||
- [ ] 批量收藏操作
|
||||
- [ ] 收藏导出(CSV/JSON)
|
||||
- [ ] 公开收藏列表(分享功能)
|
||||
- [ ] 收藏统计图表
|
||||
- [ ] 邮箱验证
|
||||
- [ ] 忘记密码
|
||||
- [ ] 头像上传
|
||||
- [ ] 第三方登录(微信、GitHub)
|
||||
|
||||
---
|
||||
|
||||
## 👥 贡献者
|
||||
|
||||
- **开发:** Claude Sonnet 4.5
|
||||
- **需求:** 项目团队
|
||||
- **测试:** 待进行
|
||||
|
||||
---
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **首次部署必须运行数据库迁移脚本**
|
||||
2. **管理员和普通用户使用不同的登录入口**
|
||||
- 管理员:`/admin/login`
|
||||
- 普通用户:`/login`
|
||||
3. **删除用户会级联删除其所有收藏和文件夹**
|
||||
4. **建议在生产环境部署前进行完整测试**
|
||||
|
||||
---
|
||||
|
||||
## 🐛 已知问题
|
||||
|
||||
- 无
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题,请查看:
|
||||
- 用户系统文档:`USER_SYSTEM_README.md`
|
||||
- Flask-Login 文档:https://flask-login.readthedocs.io/
|
||||
- SQLAlchemy 文档:https://docs.sqlalchemy.org/
|
||||
|
||||
---
|
||||
|
||||
**版本:** v3.0.0
|
||||
**发布状态:** ✅ 稳定版
|
||||
**推荐升级:** 是
|
||||
312
USER_SYSTEM_README.md
Normal file
312
USER_SYSTEM_README.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# 用户注册登录和收藏功能实现说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本次更新为ZJPB项目添加了完整的用户系统,包括:
|
||||
- ✅ 用户注册/登录(用户名+密码)
|
||||
- ✅ 工具收藏功能
|
||||
- ✅ 收藏分组管理(文件夹)
|
||||
- ✅ 用户中心(个人资料、收藏列表)
|
||||
- ✅ 多用户类型支持(Admin和User)
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 创建数据库表
|
||||
|
||||
运行数据库迁移脚本创建新表:
|
||||
|
||||
```bash
|
||||
python create_user_tables.py
|
||||
```
|
||||
|
||||
这将创建以下表:
|
||||
- `users` - 用户表
|
||||
- `folders` - 收藏分组表
|
||||
- `collections` - 收藏记录表
|
||||
|
||||
### 2. 启动应用
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
或使用现有的启动方式。
|
||||
|
||||
## 功能说明
|
||||
|
||||
### 1. 用户注册
|
||||
|
||||
**访问路径:** `/register`
|
||||
|
||||
- 用户名:至少3个字符
|
||||
- 密码:至少6个字符
|
||||
- 注册成功后自动登录并跳转到首页
|
||||
|
||||
### 2. 用户登录
|
||||
|
||||
**访问路径:** `/login`
|
||||
|
||||
- 输入用户名和密码
|
||||
- 登录成功后跳转到首页或之前访问的页面
|
||||
|
||||
### 3. 导航栏用户菜单
|
||||
|
||||
登录后,导航栏右侧会显示用户菜单:
|
||||
- 显示用户名和头像(或首字母)
|
||||
- 下拉菜单包含:
|
||||
- 👤 个人中心
|
||||
- ⭐ 我的收藏
|
||||
- 🚪 退出登录
|
||||
|
||||
### 4. 收藏功能
|
||||
|
||||
**在工具详情页:**
|
||||
- 登录后可看到"收藏"按钮
|
||||
- 点击按钮收藏/取消收藏
|
||||
- 已收藏时按钮变为金色
|
||||
- 未登录时点击会提示登录
|
||||
|
||||
**前端页面:**
|
||||
- detail_new.html - 添加了收藏按钮
|
||||
- 自动检查登录状态和收藏状态
|
||||
- Toast消息提示操作结果
|
||||
|
||||
### 5. 用户中心
|
||||
|
||||
**访问路径:** `/user/profile`
|
||||
|
||||
显示内容:
|
||||
- 侧边栏:头像、用户名、个人简介、导航菜单
|
||||
- 主内容区:
|
||||
- 统计卡片(收藏数、文件夹数)
|
||||
- 最近收藏列表(最多5条)
|
||||
|
||||
### 6. 我的收藏
|
||||
|
||||
**访问路径:** `/user/collections`
|
||||
|
||||
功能:
|
||||
- 文件夹标签页切换
|
||||
- 工具卡片网格展示
|
||||
- 分页支持
|
||||
- 空状态提示
|
||||
|
||||
## API端点
|
||||
|
||||
### 认证API
|
||||
|
||||
```
|
||||
POST /register - 用户注册(form)
|
||||
POST /login - 用户登录(form)
|
||||
GET /logout - 用户登出
|
||||
GET /api/auth/status - 获取登录状态(JSON)
|
||||
```
|
||||
|
||||
### 收藏API(需登录)
|
||||
|
||||
```
|
||||
POST /api/collections/toggle - 收藏/取消收藏
|
||||
Body: { site_code: "12345678" }
|
||||
|
||||
GET /api/collections/status/:site_code - 查询收藏状态
|
||||
Response: { is_collected: true/false, collection_id: 1, folder_id: null }
|
||||
|
||||
GET /api/collections/list - 获取收藏列表
|
||||
Query: folder_id, page, per_page
|
||||
Response: { collections: [...], total: 10, page: 1, ... }
|
||||
|
||||
PUT /api/collections/:id/note - 更新收藏备注
|
||||
Body: { note: "备注内容" }
|
||||
|
||||
PUT /api/collections/:id/move - 移动收藏到文件夹
|
||||
Body: { folder_id: 1 }
|
||||
```
|
||||
|
||||
### 文件夹API(需登录)
|
||||
|
||||
```
|
||||
GET /api/folders - 获取文件夹列表
|
||||
POST /api/folders - 创建文件夹
|
||||
Body: { name: "文件夹名", description: "", icon: "📁" }
|
||||
|
||||
PUT /api/folders/:id - 更新文件夹
|
||||
Body: { name: "", description: "", icon: "", sort_order: 0 }
|
||||
|
||||
DELETE /api/folders/:id - 删除文件夹(级联删除收藏)
|
||||
```
|
||||
|
||||
### 用户资料API(需登录)
|
||||
|
||||
```
|
||||
PUT /api/user/profile - 更新用户资料
|
||||
Body: { bio: "", avatar: "", is_public_profile: false }
|
||||
```
|
||||
|
||||
## 数据库模型
|
||||
|
||||
### User(用户表)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Integer | 主键 |
|
||||
| username | String(50) | 用户名(唯一) |
|
||||
| password_hash | String(255) | 密码哈希 |
|
||||
| email | String(100) | 邮箱(可选) |
|
||||
| avatar | String(500) | 头像URL |
|
||||
| bio | String(200) | 个人简介 |
|
||||
| is_active | Boolean | 是否启用 |
|
||||
| is_public_profile | Boolean | 是否公开资料 |
|
||||
| created_at | DateTime | 创建时间 |
|
||||
| last_login | DateTime | 最后登录时间 |
|
||||
|
||||
### Folder(收藏分组表)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Integer | 主键 |
|
||||
| user_id | Integer | 用户ID |
|
||||
| name | String(50) | 文件夹名称 |
|
||||
| description | String(200) | 描述 |
|
||||
| icon | String(50) | 图标emoji |
|
||||
| sort_order | Integer | 排序权重 |
|
||||
| is_public | Boolean | 是否公开 |
|
||||
| public_slug | String(100) | 公开链接标识 |
|
||||
| created_at | DateTime | 创建时间 |
|
||||
|
||||
### Collection(收藏记录表)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Integer | 主键 |
|
||||
| user_id | Integer | 用户ID |
|
||||
| site_id | Integer | 网站ID |
|
||||
| folder_id | Integer | 文件夹ID(可选) |
|
||||
| note | Text | 备注 |
|
||||
| created_at | DateTime | 创建时间 |
|
||||
| updated_at | DateTime | 更新时间 |
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 多用户类型支持
|
||||
|
||||
- Admin模型:`get_id()` 返回 `"admin:{id}"`
|
||||
- User模型:`get_id()` 返回 `"user:{id}"`
|
||||
- `user_loader` 根据前缀加载不同类型的用户
|
||||
|
||||
### 安全特性
|
||||
|
||||
- 密码使用 Werkzeug 的 `generate_password_hash` 加密
|
||||
- 最短密码长度:6位
|
||||
- 最短用户名长度:3位
|
||||
- Flask-Login 管理会话
|
||||
- 路由保护:`@login_required` 装饰器
|
||||
- 用户类型检查:`isinstance(current_user, User)`
|
||||
|
||||
### 前端特性
|
||||
|
||||
- Tailwind CSS 设计风格(复用admin_login.html)
|
||||
- JavaScript无侵入式增强
|
||||
- 异步API调用(Fetch)
|
||||
- Toast消息提示
|
||||
- 响应式设计
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增文件
|
||||
|
||||
```
|
||||
templates/auth/register.html - 注册页面
|
||||
templates/auth/login.html - 登录页面
|
||||
templates/user/profile.html - 用户中心
|
||||
templates/user/collections.html - 收藏列表
|
||||
create_user_tables.py - 数据库迁移脚本
|
||||
USER_SYSTEM_README.md - 本说明文档
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
```
|
||||
models.py - 添加User、Folder、Collection模型,修改Admin.get_id()
|
||||
app.py - 添加认证路由、收藏API、文件夹API、用户路由
|
||||
templates/base_new.html - 更新导航栏,添加用户菜单
|
||||
templates/detail_new.html - 添加收藏按钮和JavaScript
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. **用户注册流程**
|
||||
- 访问 `/register`
|
||||
- 输入用户名和密码
|
||||
- 提交表单
|
||||
- 验证自动登录成功
|
||||
|
||||
2. **用户登录流程**
|
||||
- 访问 `/login`
|
||||
- 输入正确的用户名和密码
|
||||
- 验证登录成功并跳转
|
||||
|
||||
3. **收藏功能**
|
||||
- 登录后访问任意工具详情页
|
||||
- 点击"收藏"按钮
|
||||
- 验证按钮状态变为"已收藏"
|
||||
- 再次点击取消收藏
|
||||
|
||||
4. **我的收藏**
|
||||
- 访问 `/user/collections`
|
||||
- 验证收藏的工具显示正确
|
||||
- 测试文件夹切换
|
||||
|
||||
5. **用户中心**
|
||||
- 访问 `/user/profile`
|
||||
- 验证统计数据正确
|
||||
- 验证最近收藏列表
|
||||
|
||||
## 后续优化方向
|
||||
|
||||
### 已实现的MVP功能
|
||||
- ✅ 基础注册登录
|
||||
- ✅ 收藏/取消收藏
|
||||
- ✅ 收藏列表展示
|
||||
- ✅ 基础文件夹管理
|
||||
|
||||
### 待实现的增强功能
|
||||
- ⏳ 收藏备注编辑UI
|
||||
- ⏳ 文件夹拖拽移动
|
||||
- ⏳ 批量收藏操作
|
||||
- ⏳ 收藏导出(CSV/JSON)
|
||||
- ⏳ 公开收藏列表(分享)
|
||||
- ⏳ 收藏统计图表
|
||||
- ⏳ 邮箱验证
|
||||
- ⏳ 忘记密码
|
||||
- ⏳ 头像上传
|
||||
- ⏳ 第三方登录
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **管理员与普通用户分离**
|
||||
- 管理员账号无法使用收藏功能
|
||||
- 管理员登录入口:`/admin/login`
|
||||
- 普通用户登录入口:`/login`
|
||||
|
||||
2. **数据完整性**
|
||||
- 删除用户会级联删除其所有收藏和文件夹
|
||||
- 删除文件夹会级联删除其中的收藏记录
|
||||
- 删除网站不会删除相关收藏(保留历史记录)
|
||||
|
||||
3. **性能优化**
|
||||
- 收藏列表查询使用了join避免N+1问题
|
||||
- 添加了必要的数据库索引
|
||||
- 分页支持大量收藏
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请查看:
|
||||
- Flask-Login 文档:https://flask-login.readthedocs.io/
|
||||
- SQLAlchemy 文档:https://docs.sqlalchemy.org/
|
||||
- Tailwind CSS 文档:https://tailwindcss.com/
|
||||
|
||||
---
|
||||
|
||||
**版本:** v1.0
|
||||
**更新日期:** 2025-02-06
|
||||
**作者:** Claude Sonnet 4.5
|
||||
661
app.py
661
app.py
@@ -9,7 +9,7 @@ from flask_admin import Admin, AdminIndexView, expose
|
||||
from flask_admin.contrib.sqla import ModelView
|
||||
from datetime import datetime
|
||||
from config import config
|
||||
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate
|
||||
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection
|
||||
from utils.website_fetcher import WebsiteFetcher
|
||||
from utils.tag_generator import TagGenerator
|
||||
from utils.news_searcher import NewsSearcher
|
||||
@@ -68,7 +68,17 @@ def create_app(config_name='default'):
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""加载用户(支持Admin和User两种类型)"""
|
||||
try:
|
||||
user_type, uid = user_id.split(':', 1)
|
||||
if user_type == 'admin':
|
||||
return AdminModel.query.get(int(uid))
|
||||
elif user_type == 'user':
|
||||
return User.query.get(int(uid))
|
||||
except (ValueError, AttributeError):
|
||||
# 兼容旧格式(纯数字ID,默认为Admin)
|
||||
return AdminModel.query.get(int(user_id))
|
||||
return None
|
||||
|
||||
# ========== 前台路由 ==========
|
||||
@app.route('/')
|
||||
@@ -593,10 +603,607 @@ def create_app(config_name='default'):
|
||||
logout_user()
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# ========== 用户认证路由 ==========
|
||||
@app.route('/register', methods=['GET', 'POST'])
|
||||
def user_register():
|
||||
"""用户注册"""
|
||||
if current_user.is_authenticated:
|
||||
# 如果已登录,跳转到首页
|
||||
return redirect(url_for('index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
confirm_password = request.form.get('confirm_password', '').strip()
|
||||
|
||||
# 验证输入
|
||||
if not username or not password:
|
||||
flash('用户名和密码不能为空', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if len(username) < 3:
|
||||
flash('用户名至少3个字符', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if len(password) < 6:
|
||||
flash('密码至少6个字符', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if password != confirm_password:
|
||||
flash('两次输入的密码不一致', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
# 检查用户名是否已存在
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('该用户名已被注册', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
# 创建用户
|
||||
try:
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 自动登录
|
||||
login_user(user)
|
||||
user.last_login = datetime.now()
|
||||
db.session.commit()
|
||||
|
||||
flash('注册成功!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'注册失败:{str(e)}', 'error')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
return render_template('auth/register.html')
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def user_login():
|
||||
"""用户登录"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
|
||||
if not username or not password:
|
||||
flash('请输入用户名和密码', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
# 查找用户
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user and user.check_password(password) and user.is_active:
|
||||
login_user(user)
|
||||
user.last_login = datetime.now()
|
||||
db.session.commit()
|
||||
|
||||
# 获取next参数,如果有则跳转,否则跳转首页
|
||||
next_page = request.args.get('next')
|
||||
if next_page and next_page.startswith('/'):
|
||||
return redirect(next_page)
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
flash('用户名或密码错误', 'error')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
@login_required
|
||||
def user_logout():
|
||||
"""用户登出"""
|
||||
logout_user()
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# ========== 用户认证API ==========
|
||||
@app.route('/api/auth/status', methods=['GET'])
|
||||
def auth_status():
|
||||
"""获取登录状态"""
|
||||
if current_user.is_authenticated:
|
||||
# 判断是Admin还是User
|
||||
if isinstance(current_user, User):
|
||||
return jsonify({
|
||||
'logged_in': True,
|
||||
'user_type': 'user',
|
||||
'username': current_user.username,
|
||||
'avatar': current_user.avatar,
|
||||
'id': current_user.id
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'logged_in': True,
|
||||
'user_type': 'admin',
|
||||
'username': current_user.username,
|
||||
'id': current_user.id
|
||||
})
|
||||
return jsonify({'logged_in': False})
|
||||
|
||||
# ========== 收藏功能API ==========
|
||||
@app.route('/api/collections/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_collection():
|
||||
"""收藏/取消收藏"""
|
||||
# 检查是否为普通用户
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '管理员账号无法使用收藏功能'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
site_code = data.get('site_code', '').strip()
|
||||
folder_id = data.get('folder_id') # 可选,指定文件夹
|
||||
|
||||
if not site_code:
|
||||
return jsonify({'success': False, 'message': '请提供网站编码'}), 400
|
||||
|
||||
# 查找网站
|
||||
site = Site.query.filter_by(code=site_code, is_active=True).first()
|
||||
if not site:
|
||||
return jsonify({'success': False, 'message': '网站不存在'}), 404
|
||||
|
||||
# 检查是否已收藏
|
||||
existing = Collection.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
site_id=site.id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 已收藏,则取消收藏
|
||||
db.session.delete(existing)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'action': 'removed',
|
||||
'message': '已取消收藏'
|
||||
})
|
||||
else:
|
||||
# 未收藏,则添加收藏
|
||||
collection = Collection(
|
||||
user_id=current_user.id,
|
||||
site_id=site.id,
|
||||
folder_id=folder_id
|
||||
)
|
||||
db.session.add(collection)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'action': 'added',
|
||||
'message': '收藏成功',
|
||||
'collection_id': collection.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'操作失败:{str(e)}'}), 500
|
||||
|
||||
@app.route('/api/collections/status/<site_code>', methods=['GET'])
|
||||
@login_required
|
||||
def collection_status(site_code):
|
||||
"""查询收藏状态"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'is_collected': False})
|
||||
|
||||
try:
|
||||
site = Site.query.filter_by(code=site_code, is_active=True).first()
|
||||
if not site:
|
||||
return jsonify({'is_collected': False})
|
||||
|
||||
collection = Collection.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
site_id=site.id
|
||||
).first()
|
||||
|
||||
return jsonify({
|
||||
'is_collected': collection is not None,
|
||||
'collection_id': collection.id if collection else None,
|
||||
'folder_id': collection.folder_id if collection else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'is_collected': False, 'error': str(e)})
|
||||
|
||||
@app.route('/api/collections/list', methods=['GET'])
|
||||
@login_required
|
||||
def list_collections():
|
||||
"""获取收藏列表"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
folder_id = request.args.get('folder_id')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
|
||||
# 构建查询
|
||||
query = Collection.query.filter_by(user_id=current_user.id)
|
||||
|
||||
if folder_id:
|
||||
if folder_id == 'null' or folder_id == 'none':
|
||||
query = query.filter_by(folder_id=None)
|
||||
else:
|
||||
query = query.filter_by(folder_id=int(folder_id))
|
||||
|
||||
# 按创建时间倒序
|
||||
query = query.order_by(Collection.created_at.desc())
|
||||
|
||||
# 分页
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
# 格式化数据
|
||||
collections_data = []
|
||||
for collection in pagination.items:
|
||||
site = collection.site
|
||||
collections_data.append({
|
||||
'id': collection.id,
|
||||
'site_id': site.id,
|
||||
'site_code': site.code,
|
||||
'site_name': site.name,
|
||||
'site_url': site.url,
|
||||
'site_logo': site.logo,
|
||||
'site_short_desc': site.short_desc,
|
||||
'folder_id': collection.folder_id,
|
||||
'note': collection.note,
|
||||
'created_at': collection.created_at.strftime('%Y-%m-%d %H:%M:%S') if collection.created_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'collections': collections_data,
|
||||
'total': pagination.total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'has_next': pagination.has_next,
|
||||
'has_prev': pagination.has_prev
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'获取失败:{str(e)}'}), 500
|
||||
|
||||
@app.route('/api/collections/<int:collection_id>/note', methods=['PUT'])
|
||||
@login_required
|
||||
def update_collection_note(collection_id):
|
||||
"""更新收藏备注"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
note = data.get('note', '').strip()
|
||||
|
||||
collection = Collection.query.filter_by(
|
||||
id=collection_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not collection:
|
||||
return jsonify({'success': False, 'message': '收藏不存在'}), 404
|
||||
|
||||
collection.note = note
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '备注已更新'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500
|
||||
|
||||
@app.route('/api/collections/<int:collection_id>/move', methods=['PUT'])
|
||||
@login_required
|
||||
def move_collection(collection_id):
|
||||
"""移动收藏到其他文件夹"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
folder_id = data.get('folder_id') # None表示移到未分类
|
||||
|
||||
collection = Collection.query.filter_by(
|
||||
id=collection_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not collection:
|
||||
return jsonify({'success': False, 'message': '收藏不存在'}), 404
|
||||
|
||||
# 如果指定了文件夹,验证文件夹是否属于当前用户
|
||||
if folder_id:
|
||||
folder = Folder.query.filter_by(
|
||||
id=folder_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'message': '文件夹不存在'}), 404
|
||||
|
||||
collection.folder_id = folder_id
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '已移动到指定文件夹'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'移动失败:{str(e)}'}), 500
|
||||
|
||||
# ========== 文件夹管理API ==========
|
||||
@app.route('/api/folders', methods=['GET'])
|
||||
@login_required
|
||||
def list_folders():
|
||||
"""获取文件夹列表"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
folders = Folder.query.filter_by(user_id=current_user.id).order_by(
|
||||
Folder.sort_order.desc(), Folder.created_at
|
||||
).all()
|
||||
|
||||
folders_data = []
|
||||
for folder in folders:
|
||||
# 统计文件夹中的收藏数量
|
||||
count = Collection.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
folder_id=folder.id
|
||||
).count()
|
||||
|
||||
folders_data.append({
|
||||
'id': folder.id,
|
||||
'name': folder.name,
|
||||
'description': folder.description,
|
||||
'icon': folder.icon,
|
||||
'sort_order': folder.sort_order,
|
||||
'is_public': folder.is_public,
|
||||
'public_slug': folder.public_slug,
|
||||
'count': count,
|
||||
'created_at': folder.created_at.strftime('%Y-%m-%d %H:%M:%S') if folder.created_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'folders': folders_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'获取失败:{str(e)}'}), 500
|
||||
|
||||
@app.route('/api/folders', methods=['POST'])
|
||||
@login_required
|
||||
def create_folder():
|
||||
"""创建文件夹"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
name = data.get('name', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
icon = data.get('icon', '📁').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': '文件夹名称不能为空'}), 400
|
||||
|
||||
# 检查同名文件夹
|
||||
existing = Folder.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
name=name
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return jsonify({'success': False, 'message': '该文件夹名称已存在'}), 400
|
||||
|
||||
# 创建文件夹
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
name=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '文件夹创建成功',
|
||||
'folder': {
|
||||
'id': folder.id,
|
||||
'name': folder.name,
|
||||
'description': folder.description,
|
||||
'icon': folder.icon
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'创建失败:{str(e)}'}), 500
|
||||
|
||||
@app.route('/api/folders/<int:folder_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_folder(folder_id):
|
||||
"""更新文件夹"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
folder = Folder.query.filter_by(
|
||||
id=folder_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'message': '文件夹不存在'}), 404
|
||||
|
||||
# 更新字段
|
||||
if 'name' in data:
|
||||
name = data['name'].strip()
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': '文件夹名称不能为空'}), 400
|
||||
|
||||
# 检查同名(排除自己)
|
||||
existing = Folder.query.filter(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == name,
|
||||
Folder.id != folder_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return jsonify({'success': False, 'message': '该文件夹名称已存在'}), 400
|
||||
|
||||
folder.name = name
|
||||
|
||||
if 'description' in data:
|
||||
folder.description = data['description'].strip()
|
||||
|
||||
if 'icon' in data:
|
||||
folder.icon = data['icon'].strip()
|
||||
|
||||
if 'sort_order' in data:
|
||||
folder.sort_order = int(data['sort_order'])
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '文件夹已更新'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500
|
||||
|
||||
@app.route('/api/folders/<int:folder_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_folder(folder_id):
|
||||
"""删除文件夹"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
folder = Folder.query.filter_by(
|
||||
id=folder_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'message': '文件夹不存在'}), 404
|
||||
|
||||
# 删除文件夹(级联删除收藏记录)
|
||||
db.session.delete(folder)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '文件夹已删除'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'删除失败:{str(e)}'}), 500
|
||||
|
||||
# ========== 用户中心页面路由 ==========
|
||||
@app.route('/user/profile')
|
||||
@login_required
|
||||
def user_profile():
|
||||
"""用户中心主页"""
|
||||
if not isinstance(current_user, User):
|
||||
flash('仅普通用户可访问', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# 统计信息
|
||||
collections_count = Collection.query.filter_by(user_id=current_user.id).count()
|
||||
folders_count = Folder.query.filter_by(user_id=current_user.id).count()
|
||||
|
||||
# 最近收藏(5条)
|
||||
recent_collections = Collection.query.filter_by(
|
||||
user_id=current_user.id
|
||||
).order_by(Collection.created_at.desc()).limit(5).all()
|
||||
|
||||
return render_template('user/profile.html',
|
||||
collections_count=collections_count,
|
||||
folders_count=folders_count,
|
||||
recent_collections=recent_collections)
|
||||
|
||||
@app.route('/user/collections')
|
||||
@login_required
|
||||
def user_collections():
|
||||
"""收藏列表页面"""
|
||||
if not isinstance(current_user, User):
|
||||
flash('仅普通用户可访问', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# 获取所有文件夹
|
||||
folders = Folder.query.filter_by(user_id=current_user.id).order_by(
|
||||
Folder.sort_order.desc(), Folder.created_at
|
||||
).all()
|
||||
|
||||
# 获取收藏(分页)
|
||||
page = request.args.get('page', 1, type=int)
|
||||
folder_id = request.args.get('folder_id')
|
||||
|
||||
query = Collection.query.filter_by(user_id=current_user.id)
|
||||
|
||||
if folder_id:
|
||||
if folder_id == 'none':
|
||||
query = query.filter_by(folder_id=None)
|
||||
else:
|
||||
query = query.filter_by(folder_id=int(folder_id))
|
||||
|
||||
query = query.order_by(Collection.created_at.desc())
|
||||
pagination = query.paginate(page=page, per_page=20, error_out=False)
|
||||
|
||||
return render_template('user/collections.html',
|
||||
folders=folders,
|
||||
collections=pagination.items,
|
||||
pagination=pagination,
|
||||
current_folder_id=folder_id)
|
||||
|
||||
@app.route('/api/user/profile', methods=['PUT'])
|
||||
@login_required
|
||||
def update_user_profile():
|
||||
"""更新用户资料"""
|
||||
if not isinstance(current_user, User):
|
||||
return jsonify({'success': False, 'message': '仅普通用户可访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
if 'bio' in data:
|
||||
current_user.bio = data['bio'].strip()
|
||||
|
||||
if 'avatar' in data:
|
||||
current_user.avatar = data['avatar'].strip()
|
||||
|
||||
if 'is_public_profile' in data:
|
||||
current_user.is_public_profile = bool(data['is_public_profile'])
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '资料已更新'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'更新失败:{str(e)}'}), 500
|
||||
|
||||
@app.route('/admin/change-password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""修改密码"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
flash('无权访问此页面', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
old_password = request.form.get('old_password', '').strip()
|
||||
new_password = request.form.get('new_password', '').strip()
|
||||
@@ -643,6 +1250,10 @@ def create_app(config_name='default'):
|
||||
@login_required
|
||||
def fetch_website_info():
|
||||
"""抓取网站信息API"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
url = data.get('url', '').strip()
|
||||
@@ -690,6 +1301,10 @@ def create_app(config_name='default'):
|
||||
@login_required
|
||||
def upload_logo():
|
||||
"""上传Logo图片API"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
# 检查文件是否存在
|
||||
if 'logo' not in request.files:
|
||||
@@ -748,6 +1363,10 @@ def create_app(config_name='default'):
|
||||
@login_required
|
||||
def generate_features():
|
||||
"""使用DeepSeek自动生成网站主要功能"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
@@ -790,6 +1409,10 @@ def create_app(config_name='default'):
|
||||
@login_required
|
||||
def generate_description():
|
||||
"""使用DeepSeek自动生成网站详细介绍"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
@@ -832,6 +1455,10 @@ def create_app(config_name='default'):
|
||||
@login_required
|
||||
def generate_tags():
|
||||
"""使用DeepSeek自动生成标签"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
@@ -877,6 +1504,10 @@ def create_app(config_name='default'):
|
||||
@login_required
|
||||
def fetch_site_news():
|
||||
"""为指定网站获取最新新闻"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
site_id = data.get('site_id')
|
||||
@@ -970,6 +1601,10 @@ def create_app(config_name='default'):
|
||||
@login_required
|
||||
def fetch_all_news():
|
||||
"""批量为所有网站获取新闻"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
count_per_site = data.get('count', 5) # 每个网站获取的新闻数量
|
||||
@@ -1148,6 +1783,11 @@ Sitemap: {}sitemap.xml
|
||||
@login_required
|
||||
def seo_tools():
|
||||
"""SEO工具管理页面"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
flash('无权访问此页面', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# 检查static/sitemap.xml是否存在及最后更新时间
|
||||
sitemap_path = 'static/sitemap.xml'
|
||||
sitemap_info = None
|
||||
@@ -1168,6 +1808,10 @@ Sitemap: {}sitemap.xml
|
||||
@login_required
|
||||
def generate_static_sitemap():
|
||||
"""生成静态sitemap.xml文件"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
# 获取所有启用的网站
|
||||
sites = Site.query.filter_by(is_active=True).order_by(Site.updated_at.desc()).all()
|
||||
@@ -1240,6 +1884,10 @@ Sitemap: {}sitemap.xml
|
||||
@login_required
|
||||
def notify_search_engines():
|
||||
"""通知搜索引擎sitemap更新"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
import requests
|
||||
from urllib.parse import quote
|
||||
@@ -1401,6 +2049,11 @@ Sitemap: {}sitemap.xml
|
||||
@login_required
|
||||
def batch_import():
|
||||
"""批量导入网站"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
flash('无权访问此页面', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
from utils.bookmark_parser import BookmarkParser
|
||||
from utils.website_fetcher import WebsiteFetcher
|
||||
|
||||
@@ -1636,7 +2289,8 @@ Sitemap: {}sitemap.xml
|
||||
named_filter_urls = True
|
||||
|
||||
def is_accessible(self):
|
||||
return current_user.is_authenticated
|
||||
# 只允许Admin类型的用户访问
|
||||
return current_user.is_authenticated and isinstance(current_user, AdminModel)
|
||||
|
||||
def inaccessible_callback(self, name, **kwargs):
|
||||
return redirect(url_for('admin_login'))
|
||||
@@ -1644,7 +2298,8 @@ Sitemap: {}sitemap.xml
|
||||
class SecureAdminIndexView(AdminIndexView):
|
||||
"""需要登录的管理首页"""
|
||||
def is_accessible(self):
|
||||
return current_user.is_authenticated
|
||||
# 只允许Admin类型的用户访问
|
||||
return current_user.is_authenticated and isinstance(current_user, AdminModel)
|
||||
|
||||
def inaccessible_callback(self, name, **kwargs):
|
||||
return redirect(url_for('admin_login'))
|
||||
|
||||
36
create_user_tables.py
Normal file
36
create_user_tables.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
数据库迁移脚本:创建用户系统相关表
|
||||
运行方式:python create_user_tables.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from app import create_app
|
||||
from models import db, User, Folder, Collection
|
||||
|
||||
def create_user_tables():
|
||||
"""创建用户系统相关的数据库表"""
|
||||
app = create_app(os.getenv('FLASK_ENV', 'development'))
|
||||
|
||||
with app.app_context():
|
||||
print("开始创建用户系统表...")
|
||||
|
||||
try:
|
||||
# 创建表(如果不存在)
|
||||
db.create_all()
|
||||
print("SUCCESS: Database tables created successfully!")
|
||||
|
||||
# 显示创建的表信息
|
||||
print("\nCreated tables:")
|
||||
print("- users")
|
||||
print("- folders")
|
||||
print("- collections")
|
||||
|
||||
print("\nMigration completed!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to create tables: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_user_tables()
|
||||
85
models.py
85
models.py
@@ -148,6 +148,10 @@ class Admin(UserMixin, db.Model):
|
||||
"""验证密码"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def get_id(self):
|
||||
"""返回唯一标识,区分Admin和User"""
|
||||
return f"admin:{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Admin {self.username}>'
|
||||
|
||||
@@ -182,3 +186,84 @@ class PromptTemplate(db.Model):
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None
|
||||
}
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""普通用户模型"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='用户名')
|
||||
password_hash = db.Column(db.String(255), nullable=False, comment='密码哈希')
|
||||
email = db.Column(db.String(100), unique=True, nullable=True, index=True, comment='邮箱')
|
||||
avatar = db.Column(db.String(500), comment='头像URL')
|
||||
bio = db.Column(db.String(200), comment='个人简介')
|
||||
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
is_public_profile = db.Column(db.Boolean, default=False, comment='是否公开个人资料')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
last_login = db.Column(db.DateTime, comment='最后登录时间')
|
||||
|
||||
# 关联
|
||||
collections = db.relationship('Collection', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
||||
folders = db.relationship('Folder', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
"""设置密码"""
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
"""验证密码"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def get_id(self):
|
||||
"""返回唯一标识,区分Admin和User"""
|
||||
return f"user:{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
class Folder(db.Model):
|
||||
"""收藏分组模型"""
|
||||
__tablename__ = 'folders'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True, comment='用户ID')
|
||||
name = db.Column(db.String(50), nullable=False, comment='文件夹名称')
|
||||
description = db.Column(db.String(200), comment='文件夹描述')
|
||||
icon = db.Column(db.String(50), default='📁', comment='图标emoji')
|
||||
sort_order = db.Column(db.Integer, default=0, comment='排序权重')
|
||||
is_public = db.Column(db.Boolean, default=False, comment='是否公开')
|
||||
public_slug = db.Column(db.String(100), unique=True, nullable=True, index=True, comment='公开链接标识')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
|
||||
# 关联
|
||||
collections = db.relationship('Collection', backref='folder', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'name', name='unique_user_folder'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Folder {self.name}>'
|
||||
|
||||
class Collection(db.Model):
|
||||
"""收藏记录模型"""
|
||||
__tablename__ = 'collections'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True, comment='用户ID')
|
||||
site_id = db.Column(db.Integer, db.ForeignKey('sites.id'), nullable=False, index=True, comment='网站ID')
|
||||
folder_id = db.Column(db.Integer, db.ForeignKey('folders.id'), nullable=True, index=True, comment='文件夹ID')
|
||||
note = db.Column(db.Text, comment='备注')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, comment='更新时间')
|
||||
|
||||
# 关联
|
||||
site = db.relationship('Site', backref='collections')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'site_id', 'folder_id', name='unique_user_site_folder'),
|
||||
db.Index('idx_user_folder', 'user_id', 'folder_id'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Collection user={self.user_id} site={self.site_id}>'
|
||||
|
||||
|
||||
173
templates/auth/login.html
Normal file
173
templates/auth/login.html
Normal 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>
|
||||
192
templates/auth/register.html
Normal file
192
templates/auth/register.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
258
templates/user/collections.html
Normal file
258
templates/user/collections.html
Normal 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
245
templates/user/profile.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user