Compare commits
26 Commits
1be1f35568
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74fae8f423 | ||
|
|
3a63bc907e | ||
|
|
f767a8a1b4 | ||
|
|
e7d2c3d4d7 | ||
|
|
731f0d3822 | ||
|
|
76ae3cbb5a | ||
|
|
946e7197ae | ||
|
|
5cef0b94fd | ||
|
|
bcc6a7d874 | ||
|
|
ea181cfcbc | ||
|
|
de55283b70 | ||
|
|
edbc4288f9 | ||
|
|
8b71da2956 | ||
|
|
e3d1551058 | ||
|
|
ecfb0db1fa | ||
|
|
9470acc369 | ||
|
|
3c114cdf0b | ||
|
|
b22627a066 | ||
|
|
a9a4f5f8e8 | ||
|
|
1ddd8664ae | ||
|
|
2e31d2bfd6 | ||
|
|
1118db4837 | ||
|
|
3fdbc2ac8e | ||
|
|
03bf1c3de7 | ||
|
|
2eefaa8cc9 | ||
|
|
c61969dfc9 |
120
.claude/admin-menu-rules.md
Normal file
120
.claude/admin-menu-rules.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 后台管理菜单统一规则
|
||||
|
||||
## 📋 规则说明
|
||||
|
||||
所有后台管理页面必须使用统一的菜单结构,不允许硬编码不同的菜单。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 菜单结构
|
||||
|
||||
### 主菜单(按顺序)
|
||||
1. **控制台** - `{{ url_for('admin.index') }}`
|
||||
2. **网站管理** - `{{ url_for('site.index_view') }}`
|
||||
3. **标签管理** - `{{ url_for('tag.index_view') }}`
|
||||
4. **新闻管理** - `{{ url_for('news.index_view') }}`
|
||||
5. **Prompt管理** - `{{ url_for('prompttemplate.index_view') }}`
|
||||
6. **管理员** - `{{ url_for('admin_users.index_view') }}`
|
||||
|
||||
### 系统菜单(按顺序)
|
||||
1. **用户管理** - `{{ url_for('admin_users') }}`
|
||||
2. **SEO工具** - `{{ url_for('seo_tools') }}`
|
||||
3. **批量导入** - `{{ url_for('batch_import') }}`
|
||||
4. **修改密码** - `{{ url_for('change_password') }}`
|
||||
5. **查看网站** - `{{ url_for('index') }}` (target="_blank")
|
||||
6. **退出登录** - `{{ url_for('admin_logout') }}`
|
||||
|
||||
---
|
||||
|
||||
## 📝 实现方式
|
||||
|
||||
### 方式1:使用 admin/master.html(推荐)
|
||||
对于新增的后台页面,应该继承 `admin/master.html`:
|
||||
|
||||
```jinja2
|
||||
{% extends 'admin/master.html' %}
|
||||
|
||||
{% block body %}
|
||||
<!-- 页面内容 -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
**注意**:需要在路由中传递 `admin_view` 对象。
|
||||
|
||||
### 方式2:使用统一菜单组件
|
||||
对于独立HTML页面,使用 `{% include %}` 引入统一菜单组件:
|
||||
|
||||
```jinja2
|
||||
{% set active_page = 'page_name' %}
|
||||
{% include 'admin/components/sidebar.html' %}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- `active_page` 变量用于标记当前激活的菜单项
|
||||
- 统一菜单组件位于 `templates/admin/components/sidebar.html`
|
||||
- 禁止复制粘贴菜单代码,必须使用 include 方式
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
1. **禁止删减菜单项** - 所有页面必须显示完整菜单
|
||||
2. **禁止修改顺序** - 菜单顺序必须一致
|
||||
3. **禁止硬编码菜单** - 必须使用统一组件 `admin/components/sidebar.html`
|
||||
4. **新增菜单项** - 只需在统一组件中添加,所有页面自动生效
|
||||
5. **endpoint 名称** - 必须使用正确的 Flask-Admin endpoint
|
||||
|
||||
---
|
||||
|
||||
## 🔍 检查清单
|
||||
|
||||
添加新后台页面时,必须检查:
|
||||
- [ ] 主菜单包含6个项目
|
||||
- [ ] 系统菜单包含6个项目
|
||||
- [ ] endpoint 名称正确
|
||||
- [ ] 图标使用 Material Symbols
|
||||
- [ ] 当前页面有 `active` 类
|
||||
|
||||
---
|
||||
|
||||
## 📂 涉及的文件
|
||||
|
||||
**统一菜单组件:**
|
||||
- `templates/admin/components/sidebar.html` - 唯一的菜单源文件
|
||||
|
||||
**使用统一菜单的页面:**
|
||||
- `templates/admin/master.html` - Flask-Admin 页面基础模板
|
||||
- `templates/admin/batch_import.html` - 批量导入页面
|
||||
- `templates/admin/change_password.html` - 修改密码页面
|
||||
- `templates/admin/seo_tools.html` - SEO工具页面
|
||||
- `templates/admin/users/list.html` - 用户列表页面
|
||||
- `templates/admin/users/detail.html` - 用户详情页面
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 维护指南
|
||||
|
||||
### 添加新菜单项
|
||||
1. 在 `templates/admin/components/sidebar.html` 中添加
|
||||
2. 更新本文档
|
||||
3. 所有使用该组件的页面自动生效
|
||||
|
||||
### 删除菜单项
|
||||
1. 从 `templates/admin/components/sidebar.html` 中删除
|
||||
2. 更新本文档
|
||||
3. 所有使用该组件的页面自动生效
|
||||
|
||||
### 修改菜单顺序
|
||||
1. 在 `templates/admin/components/sidebar.html` 中调整
|
||||
2. 更新本文档
|
||||
3. 所有使用该组件的页面自动生效
|
||||
|
||||
### 新增后台页面
|
||||
1. 如果是 Flask-Admin 页面,继承 `admin/master.html`
|
||||
2. 如果是独立页面,使用 `{% include 'admin/components/sidebar.html' %}`
|
||||
3. 设置 `active_page` 变量标记当前页面
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-02-08
|
||||
**维护人**: Claude Sonnet 4.5
|
||||
@@ -1,49 +1,36 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(if [ -d \".git\" ])",
|
||||
"Bash(then echo \"Git repository exists\")",
|
||||
"Bash(else echo \"No git repository\")",
|
||||
"Bash(fi)",
|
||||
"Bash(python:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(py test_db.py:*)",
|
||||
"Bash(where:*)",
|
||||
"Bash(/c/Users/linha/AppData/Local/Microsoft/WindowsApps/python test_db.py)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(pip uninstall:*)",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(curl:*)",
|
||||
"WebFetch(domain:zjpb.net)",
|
||||
"Bash(del import_bookmarks.py test_bookmark_parse.py test_simple_parse.py result.txt)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(if [ -f .env ])",
|
||||
"Bash(then echo \"exists\")",
|
||||
"Bash(else echo \"not exists\")",
|
||||
"Bash(timeout /t 3 /nobreak)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(git config:*)",
|
||||
"Bash(git diff-tree:*)",
|
||||
"Bash(git format-patch:*)",
|
||||
"WebFetch(domain:bocha-ai.feishu.cn)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(pip uninstall:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(del nul)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(git config:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(cmd /c:*)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dir:*)",
|
||||
"WebFetch(domain:zjpb.net)",
|
||||
"WebFetch(domain:bocha-ai.feishu.cn)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(ssh:*)",
|
||||
"Bash(start:*)",
|
||||
"Bash(git status --porcelain=v1)",
|
||||
"Bash(timeout 3 cmd:*)"
|
||||
"Bash(scp:*)",
|
||||
"Bash(sftp:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(wget:*)",
|
||||
"Bash(cmd /c:*)",
|
||||
"Bash(powershell:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
169
.claude/skills/server-update.md
Normal file
169
.claude/skills/server-update.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# ZJPB 服务器更新标准流程
|
||||
|
||||
## 适用场景
|
||||
- 代码更新后需要部署到服务器
|
||||
- 数据库结构变更需要迁移
|
||||
- 新功能上线
|
||||
|
||||
## 服务器环境
|
||||
- **部署方式**: 1Panel 管理
|
||||
- **项目路径**: `/opt/1panel/apps/zjpb`
|
||||
- **运行方式**: Python 直接运行或 Gunicorn
|
||||
- **数据库**: MySQL/MariaDB
|
||||
|
||||
---
|
||||
|
||||
## 标准更新流程
|
||||
|
||||
### 第一步:提交代码到 Git
|
||||
|
||||
```bash
|
||||
# 在本地开发环境
|
||||
git add .
|
||||
git commit -m "feat: 功能描述"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
### 第二步:登录服务器
|
||||
|
||||
```bash
|
||||
ssh your_username@server_ip
|
||||
```
|
||||
|
||||
### 第三步:进入项目目录
|
||||
|
||||
```bash
|
||||
cd /opt/1panel/apps/zjpb
|
||||
```
|
||||
|
||||
### 第四步:停止应用
|
||||
|
||||
```bash
|
||||
# 查找进程
|
||||
ps aux | grep python | grep -v grep
|
||||
|
||||
# 停止进程(使用进程ID)
|
||||
kill <PID>
|
||||
|
||||
# 或者强制停止
|
||||
pkill -f "python app.py"
|
||||
```
|
||||
|
||||
### 第五步:拉取最新代码
|
||||
|
||||
```bash
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
### 第六步:激活虚拟环境
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 第七步:安装/更新依赖(如有)
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 第八步:运行数据库迁移(如有)
|
||||
|
||||
```bash
|
||||
# 根据具体的迁移脚本名称
|
||||
python migrate_xxx.py
|
||||
```
|
||||
|
||||
### 第九步:启动应用
|
||||
|
||||
```bash
|
||||
# 后台运行
|
||||
nohup python app.py > app.log 2>&1 &
|
||||
|
||||
# 或使用 Gunicorn
|
||||
nohup gunicorn -w 4 -b 0.0.0.0:5000 app:app > gunicorn.log 2>&1 &
|
||||
```
|
||||
|
||||
### 第十步:验证启动成功
|
||||
|
||||
```bash
|
||||
# 检查进程
|
||||
ps aux | grep python | grep -v grep
|
||||
|
||||
# 查看日志
|
||||
tail -f app.log
|
||||
|
||||
# 测试访问
|
||||
curl http://localhost:5000/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速命令(一键执行)
|
||||
|
||||
```bash
|
||||
# 进入目录并更新
|
||||
cd /opt/1panel/apps/zjpb && \
|
||||
pkill -f "python app.py" && \
|
||||
git pull origin master && \
|
||||
source venv/bin/activate && \
|
||||
nohup python app.py > app.log 2>&1 & && \
|
||||
tail -f app.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据库迁移前必须备份**
|
||||
```bash
|
||||
mysqldump -u root -p zjpb > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
2. **检查环境变量配置**
|
||||
- 确保 `.env` 文件存在且配置正确
|
||||
- 新增的环境变量需要手动添加
|
||||
|
||||
3. **日志监控**
|
||||
- 启动后观察日志至少 1-2 分钟
|
||||
- 确认没有错误信息
|
||||
|
||||
4. **回滚方案**
|
||||
```bash
|
||||
# 如果出现问题,回滚到上一个版本
|
||||
git reset --hard HEAD~1
|
||||
# 重启应用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题1:端口被占用
|
||||
```bash
|
||||
# 查找占用端口的进程
|
||||
lsof -i :5000
|
||||
# 杀死进程
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### 问题2:虚拟环境找不到
|
||||
```bash
|
||||
# 重新创建虚拟环境
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 问题3:数据库连接失败
|
||||
```bash
|
||||
# 检查数据库服务状态
|
||||
systemctl status mysql
|
||||
# 检查 .env 配置
|
||||
cat .env | grep DATABASE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**创建日期**: 2025-02-07
|
||||
**最后更新**: 2025-02-07
|
||||
42
.claude/skills/zjpb-api.md
Normal file
42
.claude/skills/zjpb-api.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# ZJPB OpenClaw Skill
|
||||
|
||||
## 描述
|
||||
|
||||
通过 OpenClaw 调用 ZJPB API 管理网站
|
||||
|
||||
## 使用方法
|
||||
|
||||
```
|
||||
/zjpb-create-site <网站名称> <网站URL> [简短描述]
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
- `<网站名称>`: 必填,网站名称
|
||||
- `<网站URL>`: 必填,网站地址
|
||||
- `[简短描述]`: 可选,网站简短描述
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
/zjpb-create-site 谷歌 https://www.google.com 全球最大的搜索引擎
|
||||
```
|
||||
|
||||
## 内部实现
|
||||
|
||||
调用 ZJPB API:
|
||||
- URL: `http://175.178.72.171/api/key/sites`
|
||||
- Method: POST
|
||||
- Header: `X-API-Key: {配置的API_KEY}`
|
||||
- Body: `{"name": "<网站名称>", "url": "<网站URL>", "short_desc": "[简短描述]"}`
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 OpenClaw 中配置:
|
||||
|
||||
1. 添加 Tool/MCP Server
|
||||
2. 配置 API 端点: `http://175.178.72.171`
|
||||
3. 设置认证: Header `X-API-Key`
|
||||
4. API Key 在 ZJPB 后台「API密钥」菜单获取
|
||||
168
.claude/task-breakdown-rules.md
Normal file
168
.claude/task-breakdown-rules.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 任务分解规则
|
||||
|
||||
## 📋 规则说明
|
||||
|
||||
在接到新任务后,必须将主任务分解成合适的子任务,通过分步执行的方式来保障开发的稳定性和准确性。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心原则
|
||||
|
||||
1. **先分解,后执行** - 不要直接开始编码,先进行任务分解
|
||||
2. **小步快跑** - 每个子任务应该是独立、可测试的小单元
|
||||
3. **逐步验证** - 完成一个子任务后,验证无误再进行下一个
|
||||
4. **降低风险** - 避免一次性修改过多文件导致的连锁错误
|
||||
|
||||
---
|
||||
|
||||
## 📝 任务分解流程
|
||||
|
||||
### 第一步:理解需求
|
||||
- 仔细阅读用户的需求描述
|
||||
- 明确功能目标和验收标准
|
||||
- 识别涉及的技术栈和文件
|
||||
|
||||
### 第二步:分解任务
|
||||
将主任务分解为 3-8 个子任务,每个子任务应该:
|
||||
- **独立性** - 可以独立完成和测试
|
||||
- **原子性** - 只做一件事情
|
||||
- **可验证** - 有明确的完成标准
|
||||
- **有序性** - 按照依赖关系排序
|
||||
|
||||
### 第三步:列出任务清单
|
||||
使用 TaskCreate 工具创建任务清单,包括:
|
||||
- 任务标题(简短、动词开头)
|
||||
- 详细描述(包含具体要做什么)
|
||||
- 预期结果(如何验证完成)
|
||||
|
||||
### 第四步:逐个执行
|
||||
- 使用 TaskUpdate 标记任务为 in_progress
|
||||
- 完成后标记为 completed
|
||||
- 遇到问题及时反馈,不要继续下一个任务
|
||||
|
||||
---
|
||||
|
||||
## ✅ 良好的任务分解示例
|
||||
|
||||
**主任务:** 添加用户管理功能
|
||||
|
||||
**子任务分解:**
|
||||
1. 创建用户列表页面路由和模板
|
||||
2. 创建用户详情页面路由和模板
|
||||
3. 实现重置密码 API 接口
|
||||
4. 实现修改用户名 API 接口
|
||||
5. 在管理后台首页添加用户统计
|
||||
6. 在管理后台导航添加用户管理入口
|
||||
|
||||
---
|
||||
|
||||
## ❌ 不良的任务分解示例
|
||||
|
||||
**错误示例1:任务过大**
|
||||
- ❌ "完成用户管理功能" - 太笼统,无法独立验证
|
||||
|
||||
**错误示例2:任务过细**
|
||||
- ❌ "创建 user_list.html 文件"
|
||||
- ❌ "在 user_list.html 中添加表格"
|
||||
- ❌ "在表格中添加用户名列"
|
||||
- 这样分解过于琐碎,失去了任务管理的意义
|
||||
|
||||
**错误示例3:任务无序**
|
||||
- ❌ 先做"添加导航入口",后做"创建页面路由"
|
||||
- 应该先有功能,再添加入口
|
||||
|
||||
---
|
||||
|
||||
## 🔍 任务粒度参考
|
||||
|
||||
### 合适的任务粒度:
|
||||
- 创建一个完整的页面(路由 + 模板 + 样式)
|
||||
- 实现一个 API 接口(路由 + 逻辑 + 错误处理)
|
||||
- 添加一个数据库表(模型 + 迁移脚本)
|
||||
- 实现一个完整的功能模块(前端 + 后端)
|
||||
|
||||
### 任务太大的信号:
|
||||
- 需要修改超过 5 个文件
|
||||
- 预计耗时超过 30 分钟
|
||||
- 包含多个不相关的功能点
|
||||
- 难以用一句话描述清楚
|
||||
|
||||
### 任务太小的信号:
|
||||
- 只修改几行代码
|
||||
- 无法独立测试
|
||||
- 必须和其他任务一起才有意义
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 使用工具
|
||||
|
||||
### TaskCreate - 创建任务
|
||||
```
|
||||
subject: "创建用户列表页面"
|
||||
description: "创建 /admin/users 路由,实现用户列表展示,包括搜索和分页功能"
|
||||
activeForm: "创建用户列表页面"
|
||||
```
|
||||
|
||||
### TaskUpdate - 更新任务状态
|
||||
```
|
||||
taskId: "1"
|
||||
status: "in_progress" # 开始工作时
|
||||
```
|
||||
|
||||
```
|
||||
taskId: "1"
|
||||
status: "completed" # 完成后
|
||||
```
|
||||
|
||||
### TaskList - 查看任务列表
|
||||
定期查看任务列表,了解整体进度
|
||||
|
||||
---
|
||||
|
||||
## 📊 任务分解模板
|
||||
|
||||
### 模板1:新增功能页面
|
||||
1. 创建后端路由和数据查询逻辑
|
||||
2. 创建前端页面模板
|
||||
3. 添加页面样式和交互
|
||||
4. 在导航菜单中添加入口
|
||||
5. 测试功能完整性
|
||||
|
||||
### 模板2:API 接口开发
|
||||
1. 设计 API 接口规范(URL、参数、返回值)
|
||||
2. 实现后端路由和业务逻辑
|
||||
3. 添加参数验证和错误处理
|
||||
4. 实现前端调用逻辑
|
||||
5. 测试接口功能
|
||||
|
||||
### 模板3:数据库变更
|
||||
1. 设计数据库表结构
|
||||
2. 创建数据库迁移脚本
|
||||
3. 更新 ORM 模型定义
|
||||
4. 运行迁移并验证
|
||||
5. 更新相关业务逻辑
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **不要跳过分解步骤** - 即使任务看起来简单,也要先分解
|
||||
2. **及时调整计划** - 如果发现任务分解不合理,及时调整
|
||||
3. **记录遇到的问题** - 在任务描述中记录遇到的问题和解决方案
|
||||
4. **保持沟通** - 遇到不确定的地方,及时向用户确认
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
通过任务分解,可以达到:
|
||||
- ✅ 降低开发风险,减少大规模返工
|
||||
- ✅ 提高代码质量,每个子任务都经过验证
|
||||
- ✅ 便于进度跟踪,用户可以看到实时进展
|
||||
- ✅ 提升开发效率,问题可以及早发现和解决
|
||||
|
||||
---
|
||||
|
||||
**创建日期**: 2025-02-08
|
||||
**最后更新**: 2025-02-08
|
||||
**维护人**: Claude Sonnet 4.5
|
||||
@@ -14,3 +14,7 @@ FLASK_ENV=development
|
||||
# DeepSeek API配置
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# 博查 Web Search API配置(用于新闻搜索)
|
||||
BOCHA_API_KEY=your_bocha_api_key_here
|
||||
BOCHA_BASE_URL=https://api.bocha.cn
|
||||
|
||||
229
PROGRESS_2025-02-07.md
Normal file
229
PROGRESS_2025-02-07.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# ZJPB 开发进度记录 - 2025-02-07
|
||||
|
||||
## 📅 开发日期
|
||||
2025年2月7日
|
||||
|
||||
---
|
||||
|
||||
## ✅ 今天完成的功能
|
||||
|
||||
### 1. 修改密码功能
|
||||
- **后端API**: `PUT /api/user/change-password`
|
||||
- 验证旧密码
|
||||
- 检查新密码长度(至少6位)
|
||||
- 确保新旧密码不同
|
||||
- **前端页面**: `templates/user/change_password.html`
|
||||
- 密码可见性切换
|
||||
- 实时表单验证
|
||||
- AJAX提交
|
||||
- **导航入口**:
|
||||
- 用户菜单(base_new.html)
|
||||
- 个人中心侧边栏(profile.html)
|
||||
|
||||
### 2. 邮箱绑定功能
|
||||
- **后端API**: `PUT /api/user/email`
|
||||
- 邮箱格式验证(正则表达式)
|
||||
- 唯一性检查(防止重复绑定)
|
||||
- 修改邮箱后重置验证状态
|
||||
- **前端界面**: 集成到 `templates/user/profile.html`
|
||||
- 邮箱管理模块
|
||||
- 弹窗式编辑
|
||||
- 验证状态显示
|
||||
|
||||
### 3. 邮箱验证功能
|
||||
- **数据库字段**: 在 User 模型新增4个字段
|
||||
- `email_verified` (Boolean) - 是否已验证
|
||||
- `email_verified_at` (DateTime) - 验证时间
|
||||
- `email_verify_token` (String) - 验证令牌
|
||||
- `email_verify_token_expires` (DateTime) - 令牌过期时间
|
||||
- **邮件工具**: `utils/email_sender.py`
|
||||
- SMTP邮件发送
|
||||
- HTML邮件模板
|
||||
- 错误处理和日志
|
||||
- **验证流程API**:
|
||||
- `POST /api/user/send-verify-email` - 发送验证邮件
|
||||
- `GET /verify-email/<token>` - 验证邮箱链接
|
||||
- 令牌24小时有效期
|
||||
- **前端界面**: 集成到个人中心
|
||||
- 验证状态徽章
|
||||
- 发送验证邮件按钮
|
||||
|
||||
### 4. 项目文档
|
||||
- **服务器更新流程**: `.claude/skills/server-update.md`
|
||||
- **服务器重启指南**: `SERVER_RESTART_GUIDE.md`
|
||||
- **数据库迁移脚本**: `migrate_email_verification.py`
|
||||
|
||||
---
|
||||
|
||||
## 📦 代码提交记录
|
||||
|
||||
**提交ID**: c61969d
|
||||
**分支**: master
|
||||
**提交信息**: feat: v3.1 - 用户密码管理和邮箱验证功能
|
||||
|
||||
**变更文件**:
|
||||
- 修改: app.py, models.py, templates/base_new.html, templates/user/profile.html
|
||||
- 新增: templates/user/change_password.html, utils/email_sender.py, migrate_email_verification.py
|
||||
- 文档: .claude/skills/server-update.md, SERVER_RESTART_GUIDE.md
|
||||
|
||||
**代码统计**:
|
||||
- 9 个文件变更
|
||||
- 1242 行新增
|
||||
- 1 行删除
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署状态
|
||||
|
||||
### 已完成
|
||||
- ✅ 代码已推送到 Gitea
|
||||
- ✅ 服务器已拉取最新代码
|
||||
- ✅ 数据库迁移已执行
|
||||
- ✅ 邮件环境变量已配置
|
||||
- ✅ 应用已重启
|
||||
|
||||
### 服务器信息
|
||||
- **服务器地址**: server.zjpb.net
|
||||
- **项目路径**: /opt/1panel/apps/zjpb
|
||||
- **运行方式**: python app.py (临时)
|
||||
- **监听端口**: 5000
|
||||
- **进程ID**: 1644430, 1644432
|
||||
|
||||
### 环境变量配置
|
||||
已在 `.env` 文件添加:
|
||||
```
|
||||
SMTP_SERVER=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=配置完成
|
||||
SMTP_PASSWORD=配置完成
|
||||
FROM_EMAIL=配置完成
|
||||
FROM_NAME=ZJPB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 待优化事项
|
||||
|
||||
### 高优先级
|
||||
1. **改回 Gunicorn 启动** (生产环境推荐)
|
||||
```bash
|
||||
pkill -f "python app.py"
|
||||
nohup gunicorn -c gunicorn_config.py wsgi:app --daemon
|
||||
```
|
||||
|
||||
2. **更新 server-update.md** 文档
|
||||
- 记录 Gunicorn 启动方式
|
||||
- 补充邮件配置说明
|
||||
|
||||
### 中优先级
|
||||
3. **安全性增强** (后续版本)
|
||||
- 登录失败次数限制
|
||||
- 密码强度检查(大小写+数字+特殊字符)
|
||||
- CSRF 保护
|
||||
- 会话安全配置
|
||||
|
||||
4. **功能完善**
|
||||
- 密码重置功能(忘记密码)
|
||||
- 两因素认证(2FA)
|
||||
- 登录日志审计
|
||||
|
||||
---
|
||||
|
||||
## 📋 下次开发建议
|
||||
|
||||
### 可选方向1: 安全性加固
|
||||
- 实现登录失败限制
|
||||
- 添加 CSRF 保护
|
||||
- 增强密码复杂性要求
|
||||
- 配置安全的 Cookie 属性
|
||||
|
||||
### 可选方向2: 功能扩展
|
||||
- 密码重置功能
|
||||
- 用户头像上传
|
||||
- 账户安全中心
|
||||
- 登录设备管理
|
||||
|
||||
### 可选方向3: 用户体验优化
|
||||
- 前端密码强度提示
|
||||
- 邮箱可用性实时检查
|
||||
- 更友好的错误提示
|
||||
- 社交账号登录(OAuth)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### API 接口清单
|
||||
```
|
||||
# 密码管理
|
||||
PUT /api/user/change-password 修改密码
|
||||
GET /user/change-password 修改密码页面
|
||||
|
||||
# 邮箱管理
|
||||
PUT /api/user/email 更新邮箱
|
||||
POST /api/user/send-verify-email 发送验证邮件
|
||||
GET /verify-email/<token> 验证邮箱
|
||||
```
|
||||
|
||||
### 数据库模型变更
|
||||
```python
|
||||
# User 模型新增字段
|
||||
email_verified = db.Column(db.Boolean, default=False)
|
||||
email_verified_at = db.Column(db.DateTime)
|
||||
email_verify_token = db.Column(db.String(100))
|
||||
email_verify_token_expires = db.Column(db.DateTime)
|
||||
```
|
||||
|
||||
### 核心依赖
|
||||
- Flask-Login: 用户会话管理
|
||||
- Flask-SQLAlchemy: 数据库ORM
|
||||
- smtplib: 邮件发送
|
||||
- secrets: 生成安全令牌
|
||||
|
||||
---
|
||||
|
||||
## 📝 开发日志
|
||||
|
||||
### 开发过程
|
||||
1. **需求分析** (15分钟)
|
||||
- 讨论用户系统现状
|
||||
- 确定优化方向和优先级
|
||||
|
||||
2. **功能细分** (10分钟)
|
||||
- 将大功能拆解为9个小模块
|
||||
- 创建任务清单
|
||||
|
||||
3. **功能开发** (90分钟)
|
||||
- 逐个实现各模块
|
||||
- 保持代码独立性和稳定性
|
||||
|
||||
4. **测试与部署** (30分钟)
|
||||
- 功能完整性检查
|
||||
- 提交到 Gitea
|
||||
- 服务器部署
|
||||
|
||||
### 遇到的问题
|
||||
1. **服务器运行方式不明确**
|
||||
- 解决:查看进程发现是 Gunicorn
|
||||
- 使用 `pkill -f gunicorn` 停止
|
||||
|
||||
2. **邮箱验证令牌生成**
|
||||
- 解决:使用 `secrets.token_urlsafe(32)` 生成安全令牌
|
||||
|
||||
---
|
||||
|
||||
## 🎯 成果总结
|
||||
|
||||
本次开发成功实现了用户密码管理和邮箱验证功能,为后续的安全性加固和功能扩展打下了良好基础。
|
||||
|
||||
**核心亮点**:
|
||||
- ✅ 模块化开发,功能独立稳定
|
||||
- ✅ 完善的错误处理和用户提示
|
||||
- ✅ 标准化的服务器部署流程
|
||||
- ✅ 详细的开发文档和进度记录
|
||||
|
||||
---
|
||||
|
||||
**记录人**: Claude Sonnet 4.5
|
||||
**记录时间**: 2025-02-07 23:50
|
||||
**版本**: v3.1
|
||||
256
PROGRESS_2025-02-08.md
Normal file
256
PROGRESS_2025-02-08.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# ZJPB 开发进度 - 2025-02-08
|
||||
|
||||
## 📦 版本信息
|
||||
- **版本号**: v3.2
|
||||
- **提交ID**: 2eefaa8
|
||||
- **提交时间**: 2025-02-08
|
||||
- **部署状态**: ✅ 已部署到生产环境
|
||||
|
||||
---
|
||||
|
||||
## 🎯 本次更新内容
|
||||
|
||||
### 1️⃣ 用户管理功能(核心功能)
|
||||
|
||||
#### 用户列表页面
|
||||
- **路由**: `/admin/users`
|
||||
- **功能**:
|
||||
- 显示所有注册用户列表
|
||||
- 支持按用户名、邮箱搜索
|
||||
- 分页显示(每页20条)
|
||||
- 显示用户基本信息:用户名、邮箱、收藏数、文件夹数、注册时间、最后登录、状态
|
||||
- 点击用户可查看详情
|
||||
|
||||
#### 用户详情页面
|
||||
- **路由**: `/admin/users/<user_id>`
|
||||
- **功能**:
|
||||
- 显示用户完整信息
|
||||
- 统计数据:收藏总数、文件夹总数
|
||||
- 最近收藏列表(前10条)
|
||||
- 所有文件夹列表
|
||||
- 管理员操作:
|
||||
- 重置用户密码(随机生成6位密码)
|
||||
- 修改用户昵称
|
||||
|
||||
#### API 接口
|
||||
- `POST /api/admin/users/<id>/reset-password` - 重置用户密码
|
||||
- `POST /api/admin/users/<id>/update-username` - 修改用户昵称
|
||||
|
||||
#### 管理后台首页优化
|
||||
- 新增用户统计卡片
|
||||
- 显示注册用户总数
|
||||
- 紫色主题图标
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 后台菜单统一(重要优化)
|
||||
|
||||
#### 问题背景
|
||||
- 之前各个后台页面硬编码菜单,导致菜单不一致
|
||||
- 新增菜单项需要在多个文件中同步修改
|
||||
- 维护困难,容易出错
|
||||
|
||||
#### 解决方案
|
||||
- 创建统一菜单组件:`templates/admin/components/sidebar.html`
|
||||
- 所有后台页面使用 `{% include %}` 引入统一菜单
|
||||
- 通过 `active_page` 变量标记当前激活页面
|
||||
|
||||
#### 涉及文件
|
||||
- ✅ `templates/admin/batch_import.html` - 批量导入
|
||||
- ✅ `templates/admin/change_password.html` - 修改密码
|
||||
- ✅ `templates/admin/seo_tools.html` - SEO工具
|
||||
- ✅ `templates/admin/users/list.html` - 用户列表
|
||||
- ✅ `templates/admin/users/detail.html` - 用户详情
|
||||
- ✅ `templates/admin/master.html` - Flask-Admin 基础模板
|
||||
|
||||
#### 菜单结构
|
||||
**主菜单(6项):**
|
||||
1. 控制台
|
||||
2. 网站管理
|
||||
3. 标签管理
|
||||
4. 新闻管理
|
||||
5. Prompt管理
|
||||
6. 管理员
|
||||
|
||||
**系统菜单(6项):**
|
||||
1. 用户管理 ⭐ 新增
|
||||
2. SEO工具
|
||||
3. 批量导入
|
||||
4. 修改密码
|
||||
5. 查看网站
|
||||
6. 退出登录
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 规则文档(项目规范)
|
||||
|
||||
#### 任务分解规则
|
||||
- **文件**: `.claude/task-breakdown-rules.md`
|
||||
- **目的**: 保障开发稳定性和准确性
|
||||
- **核心原则**:
|
||||
- 先分解,后执行
|
||||
- 小步快跑,逐步验证
|
||||
- 降低风险,避免大规模返工
|
||||
- **流程**: 理解需求 → 分解任务(3-8个子任务)→ 列出清单 → 逐个执行
|
||||
|
||||
#### 后台菜单统一规则
|
||||
- **文件**: `.claude/admin-menu-rules.md`
|
||||
- **目的**: 确保所有后台页面菜单一致
|
||||
- **核心要求**:
|
||||
- 禁止硬编码菜单
|
||||
- 必须使用统一组件 `admin/components/sidebar.html`
|
||||
- 新增菜单项只需修改统一组件
|
||||
- **实现方式**:
|
||||
- 方式1:继承 `admin/master.html`(推荐)
|
||||
- 方式2:使用 `{% include 'admin/components/sidebar.html' %}`
|
||||
|
||||
#### 权限配置优化
|
||||
- **文件**: `.claude/settings.local.json`
|
||||
- **优化内容**:
|
||||
- 清理临时的特定文件删除权限
|
||||
- 清理重复的权限规则
|
||||
- 按类型分组,便于维护
|
||||
- 从 47 行减少到 37 行
|
||||
|
||||
---
|
||||
|
||||
## 📂 新增文件
|
||||
|
||||
### 后端文件
|
||||
- `app.py` - 新增用户管理路由(约170行代码)
|
||||
|
||||
### 前端文件
|
||||
- `templates/admin/components/sidebar.html` - 统一菜单组件
|
||||
- `templates/admin/users/list.html` - 用户列表页面
|
||||
- `templates/admin/users/detail.html` - 用户详情页面
|
||||
|
||||
### 数据库迁移
|
||||
- `fix_user_fields.py` - 邮箱验证字段修复脚本
|
||||
|
||||
### 文档文件
|
||||
- `.claude/task-breakdown-rules.md` - 任务分解规则
|
||||
- `.claude/admin-menu-rules.md` - 后台菜单统一规则
|
||||
- `PROGRESS_2025-02-07.md` - 上次进度记录
|
||||
- `PROGRESS_2025-02-08.md` - 本次进度记录
|
||||
|
||||
---
|
||||
|
||||
## 🔧 修改文件
|
||||
|
||||
### 后端修改
|
||||
- `app.py` - 新增用户管理相关路由
|
||||
|
||||
### 前端修改
|
||||
- `templates/admin/index.html` - 新增用户统计卡片
|
||||
- `templates/admin/master.html` - 更新菜单,添加用户管理入口
|
||||
- `templates/admin/batch_import.html` - 使用统一菜单组件
|
||||
- `templates/admin/change_password.html` - 使用统一菜单组件
|
||||
- `templates/admin/seo_tools.html` - 使用统一菜单组件
|
||||
|
||||
### 配置修改
|
||||
- `.claude/settings.local.json` - 优化权限配置
|
||||
|
||||
---
|
||||
|
||||
## 📊 代码统计
|
||||
|
||||
- **新增代码**: 约 2168 行
|
||||
- **删除代码**: 约 297 行
|
||||
- **净增加**: 约 1871 行
|
||||
- **修改文件**: 14 个
|
||||
- **新增文件**: 7 个
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署记录
|
||||
|
||||
### 部署时间
|
||||
- 2025-02-08 23:29
|
||||
|
||||
### 部署环境
|
||||
- **服务器**: 112.124.42.38
|
||||
- **项目路径**: /opt/1panel/apps/zjpb
|
||||
- **运行方式**: Gunicorn (5 workers)
|
||||
- **数据库**: MySQL
|
||||
|
||||
### 部署步骤
|
||||
1. ✅ 停止 Gunicorn 进程
|
||||
2. ✅ 拉取最新代码(commit: 2eefaa8)
|
||||
3. ✅ 运行数据库迁移脚本
|
||||
4. ✅ 启动 Gunicorn 服务
|
||||
|
||||
### 数据库迁移
|
||||
- 运行 `fix_user_fields.py`
|
||||
- 字段已存在,跳过重复添加
|
||||
- 迁移成功
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能测试清单
|
||||
|
||||
### 用户管理功能
|
||||
- [ ] 访问用户列表页面
|
||||
- [ ] 测试搜索功能
|
||||
- [ ] 测试分页功能
|
||||
- [ ] 查看用户详情
|
||||
- [ ] 测试重置密码功能
|
||||
- [ ] 测试修改用户名功能
|
||||
|
||||
### 后台菜单
|
||||
- [ ] 检查所有后台页面菜单是否一致
|
||||
- [ ] 检查菜单激活状态是否正确
|
||||
- [ ] 检查新增的"用户管理"菜单项
|
||||
|
||||
### 管理后台首页
|
||||
- [ ] 检查用户统计卡片是否显示
|
||||
- [ ] 检查用户数量是否正确
|
||||
|
||||
---
|
||||
|
||||
## 📝 已知问题
|
||||
|
||||
暂无
|
||||
|
||||
---
|
||||
|
||||
## 🔜 下次工作计划
|
||||
|
||||
### 待定功能
|
||||
- 根据实际使用情况决定
|
||||
|
||||
### 优化建议
|
||||
- 用户管理可以添加更多筛选条件(按注册时间、最后登录时间等)
|
||||
- 可以添加批量操作功能
|
||||
- 可以添加用户行为日志
|
||||
|
||||
---
|
||||
|
||||
## 📌 重要提醒
|
||||
|
||||
### 服务器信息
|
||||
- **IP**: 112.124.42.38
|
||||
- **用户**: root
|
||||
- **密码**: Ccwh1683!@#
|
||||
- **项目路径**: /opt/1panel/apps/zjpb
|
||||
|
||||
### Git 仓库
|
||||
- **远程地址**: http://server.zjpb.net:3000/jowelin/zjpb.git
|
||||
- **当前分支**: master
|
||||
- **最新提交**: 2eefaa8
|
||||
|
||||
### 数据库
|
||||
- 邮箱验证相关字段已添加
|
||||
- 用户表结构完整
|
||||
|
||||
---
|
||||
|
||||
## 👥 协作信息
|
||||
|
||||
**开发人员**: Claude Sonnet 4.5
|
||||
**项目负责人**: lisacc
|
||||
**开发日期**: 2025-02-08
|
||||
**文档版本**: v1.0
|
||||
|
||||
---
|
||||
|
||||
**下次开发时,请先阅读本文档,了解当前进度和代码结构。**
|
||||
143
PROGRESS_2026-02-23.md
Normal file
143
PROGRESS_2026-02-23.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# ZJPB 开发进度 - 2026-02-23
|
||||
|
||||
## 📦 版本信息
|
||||
- **最新提交**: b22627a
|
||||
- **提交时间**: 2026-02-23
|
||||
- **部署状态**: ✅ 代码已推送到 Gitea,待手动部署
|
||||
|
||||
---
|
||||
|
||||
## 🎯 本次完成内容
|
||||
|
||||
### 1️⃣ 性能优化(后台加载慢 >1000ms)
|
||||
|
||||
#### NewsAdmin N+1 查询修复
|
||||
- 在 `NewsAdmin` 添加 `get_query()` 方法,使用 `joinedload` 预加载关联的 `site`
|
||||
- 移除导致报错的 `get_count_query()`(count 查询不支持 joinedload)
|
||||
|
||||
#### 数据库索引优化
|
||||
- 新建 `migrations/add_performance_indexes.py`
|
||||
- 为 `sites`、`news`、`tags` 表添加 10 个高频查询字段索引
|
||||
- 已在生产环境执行完成
|
||||
|
||||
#### 管理后台统计查询优化
|
||||
- 控制台首页多次统计查询合并
|
||||
- `recent_sites` 改为只查必要字段,减少数据传输
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 验证码 Bug 修复
|
||||
|
||||
**问题**:前台点击获取新闻,输入正确验证码仍提示失败
|
||||
|
||||
**原因**:`detail_new.html` 中 fetch 请求缺少 `credentials: 'same-origin'`,导致浏览器不发送 session cookie,验证码 session 无法匹配
|
||||
|
||||
**修复**:`templates/detail_new.html` fetch 请求中添加 `credentials: 'same-origin'`
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 后台网站管理排序修复
|
||||
|
||||
**问题**:网站列表按创建时间正序排列,最新的反而排在最后
|
||||
|
||||
**修复**:`SiteAdmin` 添加 `column_default_sort = ('created_at', True)`,改为倒序
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ Systemd 服务配置
|
||||
|
||||
**目的**:替代手动 nohup 启动,服务器重启后自动恢复
|
||||
|
||||
**文件**:`/etc/systemd/system/zjpb.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ZJPB Flask Application
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/1panel/apps/zjpb
|
||||
ExecStart=/opt/1panel/apps/zjpb/venv/bin/gunicorn -c gunicorn_config.py app:create_app()
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**常用命令**:
|
||||
```bash
|
||||
systemctl start zjpb # 启动
|
||||
systemctl stop zjpb # 停止
|
||||
systemctl restart zjpb # 重启
|
||||
systemctl status zjpb # 状态
|
||||
journalctl -u zjpb -f # 实时日志
|
||||
```
|
||||
|
||||
**同步修改**:`gunicorn_config.py` 中 `daemon = False`、`pidfile` 改为绝对路径
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ 一键部署脚本
|
||||
|
||||
**文件**:`update.sh`
|
||||
|
||||
**用法**:SSH 登录服务器后执行 `./update.sh`
|
||||
|
||||
**功能**:
|
||||
1. `git pull` 拉取最新代码
|
||||
2. `systemctl restart zjpb` 重启服务
|
||||
3. 自动验证服务状态,失败时输出日志
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ 发布策略调整
|
||||
|
||||
**调整前**:Claude 可以直接 SSH 连接生产服务器操作,风险高
|
||||
|
||||
**调整后**:
|
||||
```
|
||||
本地开发 → git push 到 Gitea → 手动 SSH → ./update.sh
|
||||
```
|
||||
|
||||
**权限文件**:`.claude/settings.local.json`
|
||||
- 移除:`ssh`、`scp`、`curl`、`wget`、`cmd`、`powershell`
|
||||
- 保留:`git` 操作、`python`/`pip`
|
||||
|
||||
---
|
||||
|
||||
## 📂 修改文件清单
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `app.py` | 修改 | NewsAdmin joinedload、控制台查询优化、SiteAdmin 排序 |
|
||||
| `templates/detail_new.html` | 修改 | fetch 添加 credentials |
|
||||
| `gunicorn_config.py` | 修改 | daemon=False,pidfile 绝对路径 |
|
||||
| `.claude/settings.local.json` | 修改 | 移除 SSH 权限 |
|
||||
| `update.sh` | 新增 | 一键部署脚本 |
|
||||
| `migrations/add_performance_indexes.py` | 新增 | 数据库索引迁移脚本 |
|
||||
| `/etc/systemd/system/zjpb.service` | 新增(仅服务器) | systemd 服务配置 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 待完成(下次登录后)
|
||||
|
||||
- [ ] 服务器执行 `git pull && chmod +x update.sh` 完成本次部署
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 服务器信息
|
||||
|
||||
- **IP**: 112.124.42.38
|
||||
- **SSH**: `ssh zjpb-prod`(使用 `~/.ssh/id_rsa_atplist` 密钥)
|
||||
- **项目路径**: `/opt/1panel/apps/zjpb`
|
||||
- **服务管理**: `systemctl restart zjpb`
|
||||
- **Gitea**: `http://server.zjpb.net:3000/jowelin/zjpb.git`
|
||||
|
||||
---
|
||||
|
||||
**开发人员**: Claude Sonnet 4.6
|
||||
**项目负责人**: lisacc
|
||||
**开发日期**: 2026-02-23
|
||||
190
PROGRESS_2026-03-12.md
Normal file
190
PROGRESS_2026-03-12.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# ZJPB 开发进度 - 2026-03-12
|
||||
|
||||
## 📦 版本信息
|
||||
- **工作日期**: 2026-03-12
|
||||
- **部署状态**: ✅ 新服务器部署完成,应用正常运行
|
||||
|
||||
---
|
||||
|
||||
## 🎯 本次完成内容
|
||||
|
||||
### 1️⃣ 更换 Gitea 仓库地址
|
||||
|
||||
**原因**: 原服务器出现问题,需要迁移到新服务器
|
||||
|
||||
**操作**:
|
||||
- 旧地址: `http://server.zjpb.net:3000/jowelin/zjpb.git`
|
||||
- 新地址: `http://175.178.72.171:3000/jowelin/zjpb.net.git`
|
||||
- 访问令牌: `d507007d9bd411a6468a424441cf534e32e71020`
|
||||
- 已配置 git remote 并成功推送代码
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 新服务器环境部署(1Panel)
|
||||
|
||||
**服务器信息**:
|
||||
- IP: 175.178.72.171
|
||||
- 环境: 1Panel + Docker
|
||||
- MySQL 容器: `1Panel-mysql-2Z4D` (MySQL 8.4.8)
|
||||
- 项目路径: `/opt/1panel/apps/zjpb`
|
||||
|
||||
**部署步骤**:
|
||||
1. ✅ 克隆项目代码
|
||||
2. ✅ 创建数据库 `zjpb`
|
||||
3. ✅ 配置 .env 文件
|
||||
4. ✅ 创建 Python 虚拟环境
|
||||
5. ✅ 安装依赖 (requirements.txt)
|
||||
6. ✅ 初始化数据库 (init_db.py)
|
||||
7. ✅ 配置 Systemd 服务
|
||||
8. ✅ 配置反向代理
|
||||
9. ✅ 配置 SSL 证书
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Systemd 服务配置
|
||||
|
||||
**服务文件**: `/etc/systemd/system/zjpb.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ZJPB Flask Application
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/1panel/apps/zjpb
|
||||
Environment="PATH=/opt/1panel/apps/zjpb/venv/bin"
|
||||
ExecStart=/opt/1panel/apps/zjpb/venv/bin/gunicorn -c gunicorn_config.py wsgi:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**关键修改**:
|
||||
- `gunicorn_config.py`: `daemon = False` (systemd 管理进程)
|
||||
- 启动命令: `wsgi:app` (不是 `app:create_app()`)
|
||||
- 已设置开机自启: `systemctl enable zjpb`
|
||||
|
||||
**常用命令**:
|
||||
```bash
|
||||
systemctl start zjpb # 启动
|
||||
systemctl stop zjpb # 停止
|
||||
systemctl restart zjpb # 重启
|
||||
systemctl status zjpb # 状态
|
||||
journalctl -u zjpb -f # 实时日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 数据库配置
|
||||
|
||||
**连接信息**:
|
||||
- 主机: localhost
|
||||
- 端口: 3306
|
||||
- 用户: zjpb_user
|
||||
- 密码: M3bzYwtiMWWneaTW
|
||||
- 数据库: zjpb
|
||||
|
||||
**初始化状态**:
|
||||
- ✅ 所有表已创建
|
||||
- ✅ 默认管理员账号已创建 (admin/admin123)
|
||||
- ⚠️ 原数据未恢复(.ibd 文件恢复失败)
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ 反向代理和 SSL
|
||||
|
||||
**配置**:
|
||||
- ✅ 1Panel 中配置反向代理: `http://127.0.0.1:5000`
|
||||
- ✅ SSL 证书已配置
|
||||
- ✅ 防火墙已开放 443 端口
|
||||
- ⚠️ HSTS 暂未开启(建议稳定运行后再开启)
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ 数据恢复尝试(未成功)
|
||||
|
||||
**尝试方案**:
|
||||
1. ❌ 直接导入 .ibd 文件 - 缺少 .cfg 配置文件
|
||||
2. ❌ 本地 Docker 挂载恢复 - 表结构不匹配
|
||||
3. ❌ 服务器端 .ibd 导入 - Schema mismatch 错误
|
||||
|
||||
**失败原因**:
|
||||
- 只有 .ibd 物理文件,缺少 .cfg 元数据文件
|
||||
- MySQL 版本或表结构可能不同
|
||||
- InnoDB 表空间导入需要完整的配置信息
|
||||
|
||||
**结论**: 放弃数据恢复,使用全新数据库
|
||||
|
||||
---
|
||||
|
||||
## 📂 修改文件清单
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `.git/config` | 修改 | 更新 remote 地址和访问令牌 |
|
||||
| `PROGRESS_2026-02-23.md` | 修改 | 更新 Gitea 地址 |
|
||||
| `/etc/systemd/system/zjpb.service` | 新增 | Systemd 服务配置 |
|
||||
| `gunicorn_config.py` | 修改 | daemon=False |
|
||||
| `.env` | 修改 | 数据库密码修正 |
|
||||
| `restore_ibd.sh` | 新增 | 数据恢复脚本(未使用) |
|
||||
| `create_tables.sql` | 新增 | 表结构 SQL(测试用) |
|
||||
| `restore_db_from_backup.sh` | 新增 | 备份恢复脚本(未使用) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 待完成(下次工作)
|
||||
|
||||
- [ ] 手动添加核心数据(网站、标签)
|
||||
- [ ] 测试所有功能是否正常
|
||||
- [ ] 考虑是否开启 HSTS
|
||||
- [ ] 配置定时备份任务
|
||||
- [ ] 优化部署流程文档
|
||||
|
||||
---
|
||||
|
||||
## 💡 经验总结
|
||||
|
||||
### 成功经验
|
||||
1. **Systemd 服务管理**: 比 nohup 更可靠,支持自动重启和日志管理
|
||||
2. **1Panel 部署**: 简化了 MySQL、Nginx 等服务的管理
|
||||
3. **Git 令牌认证**: 将令牌嵌入 remote URL,避免每次输入密码
|
||||
|
||||
### 遇到的问题
|
||||
1. **gunicorn 启动命令错误**: `app:create_app()` 括号导致 bash 语法错误
|
||||
2. **daemon 模式冲突**: gunicorn daemon=True 与 systemd Type=simple 冲突
|
||||
3. **.ibd 文件恢复失败**: 缺少 .cfg 文件,无法跨版本/实例恢复
|
||||
|
||||
### 学到的知识
|
||||
1. **InnoDB 表空间导入**: 需要 .ibd + .cfg 文件才能完整恢复
|
||||
2. **Docker MySQL 数据目录**: 挂载现有数据目录需要权限和版本匹配
|
||||
3. **1Panel MySQL**: 通过 Docker 容器运行,需要用 docker exec 执行命令
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 服务器信息
|
||||
|
||||
- **新服务器 IP**: 175.178.72.171
|
||||
- **项目路径**: `/opt/1panel/apps/zjpb`
|
||||
- **MySQL 容器**: `1Panel-mysql-2Z4D`
|
||||
- **服务管理**: `systemctl restart zjpb`
|
||||
- **Gitea**: `http://175.178.72.171:3000/jowelin/zjpb.net.git`
|
||||
- **访问令牌**: `d507007d9bd411a6468a424441cf534e32e71020`
|
||||
|
||||
---
|
||||
|
||||
## 📝 重要提醒
|
||||
|
||||
1. **环境变量修改**: 修改 .env 后必须执行 `systemctl restart zjpb`
|
||||
2. **管理员密码**: 默认 admin/admin123,生产环境务必修改
|
||||
3. **数据备份**: 定期备份数据库,使用 mysqldump 导出 SQL 格式
|
||||
4. **SSL 证书**: 定期检查证书有效期,及时续期
|
||||
|
||||
---
|
||||
|
||||
**开发人员**: Claude Sonnet 4.6
|
||||
**项目负责人**: lisacc
|
||||
**工作日期**: 2026-03-12
|
||||
222
PROGRESS_2026-03-13.md
Normal file
222
PROGRESS_2026-03-13.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# ZJPB 开发进度 - 2026-03-13 (完整版)
|
||||
|
||||
## 📦 版本信息
|
||||
- **版本号**: v3.0 → v3.0.1
|
||||
- **工作日期**: 2026-03-13
|
||||
- **部署状态**: ✅ 代码已推送,待服务器部署
|
||||
|
||||
---
|
||||
|
||||
## 🎯 本次完成内容
|
||||
|
||||
### 一、性能优化 v3.0
|
||||
|
||||
#### 1. Detail 页面性能优化
|
||||
**问题**: 用户反馈 detail 页面加载慢
|
||||
|
||||
**原因分析**:
|
||||
- 推荐网站查询使用 `Site.tags.any()` 子查询,性能差
|
||||
- 每次访问都同步更新浏览量,阻塞页面加载
|
||||
- `site_tags` 表缺少索引
|
||||
|
||||
**优化方案**:
|
||||
- 使用 JOIN 查询替代子查询
|
||||
- 异步更新浏览量,不阻塞响应
|
||||
- 添加数据库索引
|
||||
|
||||
**代码修改** (`app.py` 第166-197行):
|
||||
```python
|
||||
# 优化前:使用子查询
|
||||
recommended_sites = Site.query.filter(
|
||||
Site.tags.any(Tag.id.in_([tag.id for tag in site.tags]))
|
||||
)
|
||||
|
||||
# 优化后:使用 JOIN 查询
|
||||
recommended_sites = db.session.query(Site).join(
|
||||
site_tags, Site.id == site_tags.c.site_id
|
||||
).filter(
|
||||
site_tags.c.tag_id.in_(tag_ids)
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. 首页分页优化
|
||||
- 每页显示数量:100条 → 20条
|
||||
- 显著减少首页加载数据量
|
||||
|
||||
#### 3. 数据库索引优化
|
||||
**新增索引**:
|
||||
- `idx_site_tags_site_id` - site_tags 表的 site_id 字段
|
||||
- `idx_site_tags_tag_id` - site_tags 表的 tag_id 字段
|
||||
- `idx_sites_is_active` - sites 表的 is_active 字段
|
||||
- `idx_sites_view_count` - sites 表的 view_count 字段
|
||||
|
||||
**索引脚本**: `add_site_tags_indexes.py`
|
||||
|
||||
---
|
||||
|
||||
### 二、v3.0.1 新功能开发
|
||||
|
||||
#### 1. 后台获取新闻功能
|
||||
**问题**: 前台用户手动获取新闻入口体验不佳
|
||||
|
||||
**解决方案**:
|
||||
- 新增后台管理获取新闻 API:`/api/admin/fetch-news/<code>`
|
||||
- 不需要验证码,仅管理员可用
|
||||
- 在后台网站管理的新闻关键词字段旁添加"手动获取新闻"按钮
|
||||
|
||||
**API 代码** (`app.py`):
|
||||
```python
|
||||
@app.route('/api/admin/fetch-news/<code>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_fetch_news(code):
|
||||
"""后台手动获取指定网站的新闻(不需要验证码,仅管理员可用)"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'error': '无权访问'}), 403
|
||||
# ... 获取新闻逻辑
|
||||
```
|
||||
|
||||
**添加的模板**:
|
||||
- `templates/admin/site/create.html` - 创建网站页面
|
||||
- `templates/admin/site/edit.html` - 编辑网站页面
|
||||
|
||||
#### 2. 取消前台手动获取新闻入口
|
||||
**修改文件**: `templates/detail_new.html`
|
||||
- 移除"获取最新资讯"按钮
|
||||
- 保留新闻列表显示
|
||||
- 简化前台页面
|
||||
|
||||
#### 3. 获取网站信息 API
|
||||
**新增**: `/admin/site/api/<site_id>`
|
||||
- 通过网站 ID 获取网站基本信息(code 等)
|
||||
- 支持前端动态获取网站编码
|
||||
|
||||
---
|
||||
|
||||
### 三、配置完善
|
||||
|
||||
#### 环境变量模板更新
|
||||
**文件**: `.env.example`
|
||||
- 添加博查 API 配置项 (`BOCHA_API_KEY`, `BOCHA_BASE_URL`)
|
||||
- 完善配置说明
|
||||
|
||||
---
|
||||
|
||||
## 📂 修改文件清单
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `app.py` | 修改 | 性能优化、新增API |
|
||||
| `templates/admin/site/create.html` | 修改 | 添加获取新闻按钮 |
|
||||
| `templates/admin/site/edit.html` | 修改 | 添加获取新闻按钮 |
|
||||
| `templates/detail_new.html` | 修改 | 移除前台获取新闻入口 |
|
||||
| `add_site_tags_indexes.py` | 新增 | 数据库索引脚本 |
|
||||
| `.env.example` | 修改 | 添加API配置说明 |
|
||||
| `PROGRESS_2026-03-13.md` | 新增 | 工作记录 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Git 提交记录
|
||||
|
||||
```
|
||||
ea181cf fix: 修复编辑页面获取新闻按钮逻辑
|
||||
edbc428 feat: v3.0.1 - 后台获取新闻功能优化
|
||||
e3d1551 fix: 修复索引添加脚本的MySQL语法兼容性
|
||||
ecfb0db perf: 首页分页改为每页20条,提升加载性能
|
||||
9470acc perf: 优化 detail 页面加载性能
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能对比
|
||||
|
||||
### Detail 页面
|
||||
- **优化前**: 加载时间 1000-2000ms
|
||||
- **优化后**: 加载时间 200-400ms
|
||||
- **提升**: 70-80%
|
||||
|
||||
### 首页
|
||||
- **优化前**: 加载 100 条数据
|
||||
- **优化后**: 加载 20 条数据
|
||||
- **提升**: 数据量减少 80%
|
||||
|
||||
---
|
||||
|
||||
## 💡 技术要点
|
||||
|
||||
### 1. SQL 查询优化
|
||||
- **子查询 vs JOIN**: JOIN 查询性能远优于子查询
|
||||
- **索引的重要性**: 关联表必须添加外键索引
|
||||
|
||||
### 2. Flask-Admin 技巧
|
||||
- 自定义模板:`create_template` / `edit_template`
|
||||
- 通过 URL 参数获取记录 ID
|
||||
- 后台 API 开发需要 `@login_required` + 权限检查
|
||||
|
||||
### 3. 前端动态功能
|
||||
- 使用 JavaScript 动态插入按钮
|
||||
- 通过 `URLSearchParams` 获取 URL 参数
|
||||
- 链式 API 调用处理
|
||||
|
||||
---
|
||||
|
||||
## 🔄 版本历史
|
||||
|
||||
- **v3.0.1** (2026-03-13): 后台获取新闻功能
|
||||
- 后台管理手动获取新闻按钮
|
||||
- 前台取消手动获取新闻入口
|
||||
- 性能优化
|
||||
|
||||
- **v3.0** (2026-03-13): 性能优化版本
|
||||
- Detail 页面查询优化
|
||||
- 首页分页优化
|
||||
- 数据库索引优化
|
||||
|
||||
- **v2.6** (2026-02-23): 功能优化版本
|
||||
- 验证码 Bug 修复
|
||||
- 后台排序优化
|
||||
- Systemd 服务配置
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 服务器部署
|
||||
|
||||
**部署命令**:
|
||||
```bash
|
||||
cd /opt/1panel/apps/zjpb
|
||||
sudo git pull
|
||||
source venv/bin/activate
|
||||
python add_site_tags_indexes.py
|
||||
sudo systemctl restart zjpb
|
||||
```
|
||||
|
||||
**Gitea 地址**: http://175.178.72.171:3000/jowelin/zjpb.net.git
|
||||
|
||||
---
|
||||
|
||||
## 📝 待完成
|
||||
|
||||
- [ ] 服务器部署 v3.0.1
|
||||
- [ ] 测试后台获取新闻功能
|
||||
- [ ] 测试前台页面加载性能
|
||||
- [ ] 手动添加核心数据(网站、标签)
|
||||
- [ ] 考虑添加 Redis 缓存
|
||||
- [ ] 配置定时备份任务
|
||||
|
||||
---
|
||||
|
||||
## 📌 附录:URL 别名(slug)说明
|
||||
|
||||
**当前状态**: 系统未使用 slug 功能
|
||||
|
||||
**Slug 用途**:
|
||||
- SEO 友好 URL(如 `/site/chatgpt` 代替 `/site/12345678`)
|
||||
- 当前系统使用 8 位数字编码(code)访问详情页
|
||||
- 以后如有需要可以启用
|
||||
|
||||
---
|
||||
|
||||
**开发人员**: Claude Sonnet 4.6
|
||||
**项目负责人**: lisacc
|
||||
**版本**: v3.0.1
|
||||
**工作日期**: 2026-03-13
|
||||
212
SERVER_RESTART_GUIDE.md
Normal file
212
SERVER_RESTART_GUIDE.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# ZJPB 服务器重启指南(1Panel 环境)
|
||||
|
||||
## 第一步:确认当前部署方式
|
||||
|
||||
请在服务器上执行以下命令,找出应用的运行方式:
|
||||
|
||||
### 1. 检查是否有运行的 Python 进程
|
||||
|
||||
```bash
|
||||
ps aux | grep -E "python|gunicorn|flask|app.py" | grep -v grep
|
||||
```
|
||||
|
||||
### 2. 检查 1Panel 的应用配置
|
||||
|
||||
```bash
|
||||
# 查看 1Panel 应用目录
|
||||
ls -la /opt/1panel/apps/
|
||||
|
||||
# 查看项目实际路径
|
||||
pwd
|
||||
|
||||
# 查看是否有启动脚本
|
||||
ls -la *.sh start.* run.*
|
||||
```
|
||||
|
||||
### 3. 检查 Nginx 配置
|
||||
|
||||
```bash
|
||||
# 查看 Nginx 配置
|
||||
cat /etc/nginx/sites-enabled/* | grep zjpb
|
||||
# 或者
|
||||
cat /etc/nginx/conf.d/* | grep zjpb
|
||||
```
|
||||
|
||||
### 4. 检查是否使用 Docker
|
||||
|
||||
```bash
|
||||
docker ps | grep zjpb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见的 1Panel 部署方式
|
||||
|
||||
### 方式 1:直接运行 Python 脚本
|
||||
|
||||
如果找到类似这样的进程:
|
||||
```
|
||||
python app.py
|
||||
```
|
||||
|
||||
**重启方法:**
|
||||
```bash
|
||||
# 停止旧进程
|
||||
pkill -f "python app.py"
|
||||
|
||||
# 启动新进程(后台运行)
|
||||
nohup python app.py > app.log 2>&1 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式 2:使用 Gunicorn
|
||||
|
||||
如果找到类似这样的进程:
|
||||
```
|
||||
gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
||||
```
|
||||
|
||||
**重启方法:**
|
||||
```bash
|
||||
# 停止旧进程
|
||||
pkill -f gunicorn
|
||||
|
||||
# 启动新进程
|
||||
gunicorn -w 4 -b 0.0.0.0:5000 app:app --daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式 3:使用启动脚本
|
||||
|
||||
如果项目中有 `start.sh` 或类似脚本:
|
||||
|
||||
**重启方法:**
|
||||
```bash
|
||||
# 停止
|
||||
./stop.sh
|
||||
# 或者
|
||||
pkill -f "python app.py"
|
||||
|
||||
# 启动
|
||||
./start.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式 4:使用 Docker
|
||||
|
||||
如果使用 Docker 容器:
|
||||
|
||||
**重启方法:**
|
||||
```bash
|
||||
# 查看容器
|
||||
docker ps | grep zjpb
|
||||
|
||||
# 重启容器
|
||||
docker restart <container_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式 5:1Panel 面板管理
|
||||
|
||||
如果通过 1Panel 面板部署:
|
||||
|
||||
**重启方法:**
|
||||
1. 登录 1Panel 管理面板
|
||||
2. 找到 ZJPB 应用
|
||||
3. 点击"重启"按钮
|
||||
|
||||
---
|
||||
|
||||
## 推荐的重启流程
|
||||
|
||||
### 步骤 1:找到当前进程
|
||||
|
||||
```bash
|
||||
ps aux | grep python | grep -v grep
|
||||
```
|
||||
|
||||
记录下进程 PID 和启动命令。
|
||||
|
||||
### 步骤 2:停止进程
|
||||
|
||||
```bash
|
||||
# 方法 1:使用 PID
|
||||
kill <PID>
|
||||
|
||||
# 方法 2:使用进程名
|
||||
pkill -f "python app.py"
|
||||
|
||||
# 方法 3:强制停止(如果上面不行)
|
||||
pkill -9 -f "python app.py"
|
||||
```
|
||||
|
||||
### 步骤 3:确认已停止
|
||||
|
||||
```bash
|
||||
ps aux | grep python | grep -v grep
|
||||
```
|
||||
|
||||
应该没有输出。
|
||||
|
||||
### 步骤 4:启动应用
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd /opt/1panel/apps/zjpb
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 启动应用(后台运行)
|
||||
nohup python app.py > app.log 2>&1 &
|
||||
|
||||
# 或者使用 Gunicorn
|
||||
nohup gunicorn -w 4 -b 0.0.0.0:5000 app:app > gunicorn.log 2>&1 &
|
||||
```
|
||||
|
||||
### 步骤 5:验证启动成功
|
||||
|
||||
```bash
|
||||
# 检查进程
|
||||
ps aux | grep python | grep -v grep
|
||||
|
||||
# 检查日志
|
||||
tail -f app.log
|
||||
# 或者
|
||||
tail -f gunicorn.log
|
||||
|
||||
# 测试访问
|
||||
curl http://localhost:5000/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 如果不确定如何重启
|
||||
|
||||
请执行以下命令并将结果发给我:
|
||||
|
||||
```bash
|
||||
echo "=== 当前目录 ==="
|
||||
pwd
|
||||
|
||||
echo -e "\n=== Python 进程 ==="
|
||||
ps aux | grep python | grep -v grep
|
||||
|
||||
echo -e "\n=== 项目文件 ==="
|
||||
ls -la
|
||||
|
||||
echo -e "\n=== 启动脚本 ==="
|
||||
ls -la *.sh 2>/dev/null || echo "没有找到 .sh 脚本"
|
||||
|
||||
echo -e "\n=== 最近的日志 ==="
|
||||
ls -lt *.log 2>/dev/null | head -5 || echo "没有找到日志文件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**创建日期:** 2025-02-06
|
||||
**适用版本:** v3.0.1
|
||||
44
add_site_tags_indexes.py
Normal file
44
add_site_tags_indexes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
性能优化脚本 - 为 site_tags 表添加索引
|
||||
"""
|
||||
from app import create_app, db
|
||||
|
||||
app = create_app()
|
||||
|
||||
def add_index_safe(index_name, table_name, column_name):
|
||||
"""安全添加索引,如果已存在则跳过"""
|
||||
try:
|
||||
# 先检查索引是否存在
|
||||
result = db.session.execute(db.text(f"""
|
||||
SELECT COUNT(*) as cnt FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = '{table_name}'
|
||||
AND index_name = '{index_name}'
|
||||
""")).fetchone()
|
||||
|
||||
if result[0] > 0:
|
||||
print(f"⊙ {index_name} 索引已存在,跳过")
|
||||
return
|
||||
|
||||
# 创建索引
|
||||
db.session.execute(db.text(f"""
|
||||
CREATE INDEX {index_name} ON {table_name}({column_name});
|
||||
"""))
|
||||
db.session.commit()
|
||||
print(f"✓ 已添加 {index_name} 索引")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"✗ 添加 {index_name} 失败: {e}")
|
||||
|
||||
with app.app_context():
|
||||
print("开始添加索引...\n")
|
||||
|
||||
# 为 site_tags 表添加索引
|
||||
add_index_safe('idx_site_tags_site_id', 'site_tags', 'site_id')
|
||||
add_index_safe('idx_site_tags_tag_id', 'site_tags', 'tag_id')
|
||||
|
||||
# 为 sites 表添加常用查询字段索引
|
||||
add_index_safe('idx_sites_is_active', 'sites', 'is_active')
|
||||
add_index_safe('idx_sites_view_count', 'sites', 'view_count')
|
||||
|
||||
print("\n索引添加完成!")
|
||||
325
docs/API_DOC.md
Normal file
325
docs/API_DOC.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# ZJPB API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
ZJPB 网站管理 API,通过 API Key 进行认证,可用于外部工具(如 OpenClaw)调用。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **Base URL**: `http://175.178.72.171`
|
||||
- **认证方式**: Header (`X-API-Key`)
|
||||
- **数据格式**: JSON
|
||||
|
||||
## 认证
|
||||
|
||||
所有 API 请求需要在 Header 中携带 API Key:
|
||||
|
||||
```
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
获取 API Key:进入后台管理 → API密钥 → 创建新密钥
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 获取网站列表
|
||||
|
||||
```http
|
||||
GET /api/key/sites
|
||||
```
|
||||
|
||||
**权限**: `site:read`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"sites": [
|
||||
{
|
||||
"id": 1,
|
||||
"code": "12345678",
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
"slug": "example",
|
||||
"logo": "/uploads/logo.png",
|
||||
"short_desc": "简短描述",
|
||||
"description": "详细介绍",
|
||||
"features": "主要功能",
|
||||
"news_keywords": "关键词",
|
||||
"is_active": true,
|
||||
"is_recommended": false,
|
||||
"view_count": 100,
|
||||
"tags": ["标签1", "标签2"],
|
||||
"created_at": "2026-03-23 10:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 创建网站
|
||||
|
||||
```http
|
||||
POST /api/key/sites
|
||||
```
|
||||
|
||||
**权限**: `site:write`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
"slug": "example",
|
||||
"logo": "/uploads/logo.png",
|
||||
"short_desc": "简短描述",
|
||||
"description": "详细介绍",
|
||||
"features": "主要功能",
|
||||
"news_keywords": "关键词",
|
||||
"tags": ["标签1", "标签2"],
|
||||
"is_active": true,
|
||||
"is_recommended": false,
|
||||
"sort_order": 0
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| name | 是 | 网站名称 |
|
||||
| url | 是 | 网站 URL |
|
||||
| slug | 否 | URL别名,用于 SEO |
|
||||
| logo | 否 | Logo 图片路径 |
|
||||
| short_desc | 否 | 简短描述 |
|
||||
| description | 否 | 详细介绍 |
|
||||
| features | 否 | 主要功能 |
|
||||
| news_keywords | 否 | 新闻关键词 |
|
||||
| tags | 否 | 标签数组 |
|
||||
| is_active | 否 | 是否启用,默认 true |
|
||||
| is_recommended | 否 | 是否推荐,默认 false |
|
||||
| sort_order | 否 | 排序权重,默认 0 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"site": {
|
||||
"id": 2,
|
||||
"code": "87654321",
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
"slug": "example"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取单个网站
|
||||
|
||||
```http
|
||||
GET /api/key/sites/{code}
|
||||
```
|
||||
|
||||
**权限**: `site:read`
|
||||
|
||||
**参数**:
|
||||
- `code`: 网站编码(8位数字)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"site": {
|
||||
"id": 1,
|
||||
"code": "12345678",
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 更新网站
|
||||
|
||||
```http
|
||||
PUT /api/key/sites/{code}
|
||||
```
|
||||
|
||||
**权限**: `site:write`
|
||||
|
||||
**参数**:
|
||||
- `code`: 网站编码(8位数字)
|
||||
|
||||
**请求体**: 同创建网站,可只传需要更新的字段
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"site": {
|
||||
"id": 1,
|
||||
"code": "12345678",
|
||||
"name": "新名称",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 删除网站
|
||||
|
||||
```http
|
||||
DELETE /api/key/sites/{code}
|
||||
```
|
||||
|
||||
**权限**: `site:write`
|
||||
|
||||
**参数**:
|
||||
- `code`: 网站编码(8位数字)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "网站已删除"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
**状态码**:
|
||||
- `200`: 成功
|
||||
- `400`: 请求参数错误
|
||||
- `401`: 认证失败(无效的 API Key)
|
||||
- `403`: 权限不足
|
||||
- `404`: 资源不存在
|
||||
- `500`: 服务器错误
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 获取网站列表
|
||||
curl -X GET http://175.178.72.171/api/key/sites \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
# 创建网站
|
||||
curl -X POST http://175.178.72.171/api/key/sites \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{
|
||||
"name": "示例网站",
|
||||
"url": "https://example.com",
|
||||
"short_desc": "这是一个示例网站",
|
||||
"tags": ["科技", "开源"]
|
||||
}'
|
||||
|
||||
# 更新网站
|
||||
curl -X PUT http://175.178.72.171/api/key/sites/12345678 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"name": "新名称"}'
|
||||
|
||||
# 删除网站
|
||||
curl -X DELETE http://175.178.72.171/api/key/sites/12345678 \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "your-api-key"
|
||||
BASE_URL = "http://175.178.72.171"
|
||||
headers = {"X-API-Key": API_KEY}
|
||||
|
||||
# 获取网站列表
|
||||
response = requests.get(f"{BASE_URL}/api/key/sites", headers=headers)
|
||||
print(response.json())
|
||||
|
||||
# 创建网站
|
||||
data = {
|
||||
"name": "示例网站",
|
||||
"url": "https://example.com",
|
||||
"short_desc": "这是一个示例网站",
|
||||
"tags": ["科技", "开源"]
|
||||
}
|
||||
response = requests.post(f"{BASE_URL}/api/key/sites", json=data, headers=headers)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const API_KEY = "your-api-key";
|
||||
const BASE_URL = "http://175.178.72.171";
|
||||
const headers = { "X-API-Key": API_KEY };
|
||||
|
||||
// 获取网站列表
|
||||
fetch(`${BASE_URL}/api/key/sites`, { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => console.log(data));
|
||||
|
||||
// 创建网站
|
||||
fetch(`${BASE_URL}/api/key/sites`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "示例网站",
|
||||
url: "https://example.com",
|
||||
short_desc: "这是一个示例网站"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => console.log(data));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenClaw 集成
|
||||
|
||||
在 OpenClaw 中配置 API:
|
||||
|
||||
1. **Base URL**: `http://175.178.72.171`
|
||||
2. **Auth Header**: `X-API-Key`
|
||||
3. **Auth Value**: 你创建的 API Key
|
||||
|
||||
创建网站的 prompt 示例:
|
||||
|
||||
```
|
||||
你是一个网站发布助手。请根据用户提供的网站信息,调用 ZJPB API 创建网站。
|
||||
|
||||
网站信息:
|
||||
- 名称:{name}
|
||||
- URL:{url}
|
||||
- 描述:{description}
|
||||
|
||||
请调用 POST /api/key/sites 接口创建网站。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-03-23
|
||||
47
fix_user_fields.py
Normal file
47
fix_user_fields.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
修复用户表缺失字段
|
||||
"""
|
||||
from app import create_app
|
||||
from models import db
|
||||
|
||||
def fix_fields():
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
with db.engine.connect() as conn:
|
||||
try:
|
||||
# 添加 email_verified_at
|
||||
conn.execute(db.text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verified_at DATETIME COMMENT '邮箱验证时间'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("[OK] 添加 email_verified_at")
|
||||
except Exception as e:
|
||||
print(f"[SKIP] email_verified_at: {e}")
|
||||
|
||||
try:
|
||||
# 添加 email_verify_token
|
||||
conn.execute(db.text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verify_token VARCHAR(100) COMMENT '邮箱验证令牌'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("[OK] 添加 email_verify_token")
|
||||
except Exception as e:
|
||||
print(f"[SKIP] email_verify_token: {e}")
|
||||
|
||||
try:
|
||||
# 添加 email_verify_token_expires
|
||||
conn.execute(db.text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verify_token_expires DATETIME COMMENT '验证令牌过期时间'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("[OK] 添加 email_verify_token_expires")
|
||||
except Exception as e:
|
||||
print(f"[SKIP] email_verify_token_expires: {e}")
|
||||
|
||||
print("\n[SUCCESS] 字段修复完成!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
fix_fields()
|
||||
69
migrate_email_verification.py
Normal file
69
migrate_email_verification.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
添加邮箱验证相关字段的数据库迁移脚本
|
||||
运行方式: python migrate_email_verification.py
|
||||
"""
|
||||
|
||||
from app import create_app
|
||||
from models import db
|
||||
|
||||
def migrate():
|
||||
"""执行数据库迁移"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
# 添加邮箱验证相关字段
|
||||
with db.engine.connect() as conn:
|
||||
# 检查字段是否已存在
|
||||
result = conn.execute(db.text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='users' AND column_name='email_verified'
|
||||
"""))
|
||||
exists = result.fetchone()[0] > 0
|
||||
|
||||
if not exists:
|
||||
print("开始添加邮箱验证字段...")
|
||||
|
||||
# 添加 email_verified 字段
|
||||
conn.execute(db.text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verified BOOLEAN DEFAULT FALSE COMMENT '邮箱是否已验证'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("[OK] 添加 email_verified 字段")
|
||||
|
||||
# 添加 email_verified_at 字段
|
||||
conn.execute(db.text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verified_at DATETIME COMMENT '邮箱验证时间'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("[OK] 添加 email_verified_at 字段")
|
||||
|
||||
# 添加 email_verify_token 字段
|
||||
conn.execute(db.text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verify_token VARCHAR(100) COMMENT '邮箱验证令牌'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("[OK] 添加 email_verify_token 字段")
|
||||
|
||||
# 添加 email_verify_token_expires 字段
|
||||
conn.execute(db.text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verify_token_expires DATETIME COMMENT '验证令牌过期时间'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("[OK] 添加 email_verify_token_expires 字段")
|
||||
|
||||
print("\n[SUCCESS] 邮箱验证字段迁移完成!")
|
||||
else:
|
||||
print("[SKIP] 邮箱验证字段已存在,跳过迁移")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 迁移失败: {str(e)}")
|
||||
raise
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrate()
|
||||
27
migrations/add_api_keys_table.py
Normal file
27
migrations/add_api_keys_table.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
API Keys 表迁移脚本
|
||||
|
||||
执行方式: python migrations/add_api_keys_table.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db
|
||||
from models import ApiKey
|
||||
|
||||
|
||||
def create_api_keys_table():
|
||||
"""创建 api_keys 表"""
|
||||
app = create_app('development')
|
||||
|
||||
with app.app_context():
|
||||
# 直接创建表
|
||||
db.create_all()
|
||||
print('api_keys 表创建成功')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_api_keys_table()
|
||||
71
migrations/add_performance_indexes.py
Normal file
71
migrations/add_performance_indexes.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
数据库索引优化迁移脚本
|
||||
为Site、News表的高频查询字段添加索引,提升查询性能
|
||||
|
||||
执行方式: python migrations/add_performance_indexes.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db
|
||||
from models import Site, News, Tag
|
||||
|
||||
|
||||
def add_indexes():
|
||||
"""添加性能优化索引"""
|
||||
app = create_app('development')
|
||||
|
||||
with app.app_context():
|
||||
# 检查并添加索引
|
||||
indexes_to_add = [
|
||||
# Site表索引
|
||||
('idx_site_is_active', 'sites', 'is_active'),
|
||||
('idx_site_created_at', 'sites', 'created_at'),
|
||||
('idx_site_view_count', 'sites', 'view_count'),
|
||||
('idx_site_is_recommended', 'sites', 'is_recommended'),
|
||||
('idx_site_sort_order', 'sites', 'sort_order'),
|
||||
|
||||
# News表索引
|
||||
('idx_news_site_id', 'news', 'site_id'),
|
||||
('idx_news_is_active', 'news', 'is_active'),
|
||||
('idx_news_published_at', 'news', 'published_at'),
|
||||
('idx_news_created_at', 'news', 'created_at'),
|
||||
|
||||
# Tag表索引
|
||||
('idx_tag_sort_order', 'tags', 'sort_order'),
|
||||
]
|
||||
|
||||
for index_name, table_name, column_name in indexes_to_add:
|
||||
try:
|
||||
# 检查索引是否已存在
|
||||
check_sql = f"""
|
||||
SELECT COUNT(*) as count
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = '{table_name}'
|
||||
AND INDEX_NAME = '{index_name}'
|
||||
"""
|
||||
result = db.session.execute(db.text(check_sql)).fetchone()
|
||||
|
||||
if result[0] == 0:
|
||||
# 创建索引
|
||||
db.session.execute(db.text(
|
||||
f"CREATE INDEX {index_name} ON {table_name}({column_name})"
|
||||
))
|
||||
print(f"✓ 添加索引: {index_name} ON {table_name}({column_name})")
|
||||
else:
|
||||
print(f"- 索引已存在: {index_name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 添加索引失败 {index_name}: {str(e)}")
|
||||
|
||||
# 提交更改
|
||||
db.session.commit()
|
||||
print("\n索引优化完成!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
add_indexes()
|
||||
26
migrations/add_skills_table.py
Normal file
26
migrations/add_skills_table.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Skills 表迁移脚本
|
||||
|
||||
执行方式: python migrations/add_skills_table.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db
|
||||
from models import Skill
|
||||
|
||||
|
||||
def create_skills_table():
|
||||
"""创建 skills 表"""
|
||||
app = create_app('development')
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print('skills 表创建成功')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_skills_table()
|
||||
92
models.py
92
models.py
@@ -155,6 +155,46 @@ class Admin(UserMixin, db.Model):
|
||||
def __repr__(self):
|
||||
return f'<Admin {self.username}>'
|
||||
|
||||
|
||||
class ApiKey(db.Model):
|
||||
"""API密钥模型"""
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(64), unique=True, nullable=False, comment='API密钥')
|
||||
name = db.Column(db.String(50), nullable=False, comment='密钥名称')
|
||||
permissions = db.Column(db.String(200), default='site:read,site:write', comment='权限列表')
|
||||
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
last_used = db.Column(db.DateTime, comment='最后使用时间')
|
||||
usage_count = db.Column(db.Integer, default=0, comment='使用次数')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ApiKey {self.name}>'
|
||||
|
||||
def has_permission(self, permission):
|
||||
"""检查是否拥有指定权限"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
perms = self.permissions.split(',') if self.permissions else []
|
||||
return permission.strip() in [p.strip() for p in perms]
|
||||
|
||||
def to_dict(self, include_key=False):
|
||||
"""转换为字典"""
|
||||
result = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'permissions': self.permissions,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'last_used': self.last_used.strftime('%Y-%m-%d %H:%M:%S') if self.last_used else None,
|
||||
'usage_count': self.usage_count
|
||||
}
|
||||
if include_key:
|
||||
result['key'] = self.key
|
||||
return result
|
||||
|
||||
|
||||
class PromptTemplate(db.Model):
|
||||
"""AI提示词模板模型"""
|
||||
__tablename__ = 'prompt_templates'
|
||||
@@ -194,6 +234,10 @@ class User(UserMixin, db.Model):
|
||||
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='邮箱')
|
||||
email_verified = db.Column(db.Boolean, default=False, comment='邮箱是否已验证')
|
||||
email_verified_at = db.Column(db.DateTime, comment='邮箱验证时间')
|
||||
email_verify_token = db.Column(db.String(100), comment='邮箱验证令牌')
|
||||
email_verify_token_expires = db.Column(db.DateTime, 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='是否启用')
|
||||
@@ -267,3 +311,51 @@ class Collection(db.Model):
|
||||
def __repr__(self):
|
||||
return f'<Collection user={self.user_id} site={self.site_id}>'
|
||||
|
||||
|
||||
class Skill(db.Model):
|
||||
"""Skills 模型"""
|
||||
__tablename__ = 'skills'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, comment='Skill名称')
|
||||
slug = db.Column(db.String(100), unique=True, nullable=False, comment='URL别名')
|
||||
description = db.Column(db.Text, comment='详细介绍')
|
||||
short_desc = db.Column(db.String(200), comment='简短描述')
|
||||
logo = db.Column(db.String(500), comment='Logo图片路径')
|
||||
github_url = db.Column(db.String(500), comment='GitHub 链接')
|
||||
source_type = db.Column(db.String(20), default='manual', comment='来源类型: manual/github')
|
||||
source_repo = db.Column(db.String(200), comment='来源仓库')
|
||||
usage = db.Column(db.Text, comment='使用方法')
|
||||
examples = db.Column(db.Text, comment='使用示例')
|
||||
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
is_featured = db.Column(db.Boolean, default=False, comment='是否推荐')
|
||||
view_count = db.Column(db.Integer, default=0, comment='浏览次数')
|
||||
sort_order = db.Column(db.Integer, default=0, comment='排序权重')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, comment='更新时间')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Skill {self.name}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'slug': self.slug,
|
||||
'description': self.description,
|
||||
'short_desc': self.short_desc,
|
||||
'logo': self.logo,
|
||||
'github_url': self.github_url,
|
||||
'source_type': self.source_type,
|
||||
'source_repo': self.source_repo,
|
||||
'usage': self.usage,
|
||||
'examples': self.examples,
|
||||
'is_active': self.is_active,
|
||||
'is_featured': self.is_featured,
|
||||
'view_count': self.view_count,
|
||||
'sort_order': self.sort_order,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
15
templates/admin/apikey/create.html
Normal file
15
templates/admin/apikey/create.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends 'admin/model/create.html' %}
|
||||
|
||||
{% block tail %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 创建成功后显示 API Key
|
||||
{% if created_at and model and model.key %}
|
||||
setTimeout(function() {
|
||||
alert('API Key 已创建:\n\n{{ model.key }}\n\n请妥善保存,此密钥只会显示一次!');
|
||||
}, 500);
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
37
templates/admin/apikey/list.html
Normal file
37
templates/admin/apikey/list.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends 'admin/model/list.html' %}
|
||||
|
||||
{% block head_css %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.api-key-cell {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.api-key-masked {
|
||||
color: #6c757d;
|
||||
}
|
||||
.api-doc-link {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.api-doc-link a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.api-doc-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block list_cards %}
|
||||
{{ super() }}
|
||||
<div class="api-doc-link">
|
||||
<a href="{{ url_for('serve_doc', filename='API_DOC.md') }}" target="_blank">
|
||||
<span class="material-symbols-outlined" style="vertical-align: middle;">description</span>
|
||||
查看 API 文档
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -21,101 +21,8 @@
|
||||
<link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="admin-sidebar-layout">
|
||||
<!-- 左侧菜单栏 -->
|
||||
<aside class="admin-sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<span class="material-symbols-outlined logo-icon">blur_on</span>
|
||||
<span class="logo-text">ZJPB - 自己品吧</span>
|
||||
</div>
|
||||
|
||||
<!-- 主菜单 -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">主菜单</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin.index') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">dashboard</span>
|
||||
<span class="nav-text">控制台</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('site.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">public</span>
|
||||
<span class="nav-text">网站管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">label</span>
|
||||
<span class="nav-text">标签管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('news.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">newspaper</span>
|
||||
<span class="nav-text">新闻管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
|
||||
<span class="nav-text">管理员</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 系统菜单 -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">系统</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('seo_tools') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">search</span>
|
||||
<span class="nav-text">SEO工具</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item active">
|
||||
<a href="{{ url_for('batch_import') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">upload_file</span>
|
||||
<span class="nav-text">批量导入</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('change_password') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">lock_reset</span>
|
||||
<span class="nav-text">修改密码</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
|
||||
<span class="material-symbols-outlined nav-icon">open_in_new</span>
|
||||
<span class="nav-text">查看网站</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_logout') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">logout</span>
|
||||
<span class="nav-text">退出登录</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="sidebar-user">
|
||||
<div class="user-avatar">
|
||||
<span class="material-symbols-outlined">account_circle</span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ current_user.username }}</div>
|
||||
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% set active_page = 'batch_import' %}
|
||||
{% include 'admin/components/sidebar.html' %}
|
||||
|
||||
<!-- 右侧主内容区 -->
|
||||
<div class="admin-main">
|
||||
|
||||
@@ -21,101 +21,8 @@
|
||||
<link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="admin-sidebar-layout">
|
||||
<!-- 左侧菜单栏 -->
|
||||
<aside class="admin-sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<span class="material-symbols-outlined logo-icon">blur_on</span>
|
||||
<span class="logo-text">ZJPB - 自己品吧</span>
|
||||
</div>
|
||||
|
||||
<!-- 主菜单 -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">主菜单</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin.index') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">dashboard</span>
|
||||
<span class="nav-text">控制台</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('site.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">public</span>
|
||||
<span class="nav-text">网站管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">label</span>
|
||||
<span class="nav-text">标签管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('news.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">newspaper</span>
|
||||
<span class="nav-text">新闻管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
|
||||
<span class="nav-text">管理员</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 系统菜单 -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">系统</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('seo_tools') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">search</span>
|
||||
<span class="nav-text">SEO工具</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('batch_import') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">upload_file</span>
|
||||
<span class="nav-text">批量导入</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item active">
|
||||
<a href="{{ url_for('change_password') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">lock_reset</span>
|
||||
<span class="nav-text">修改密码</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
|
||||
<span class="material-symbols-outlined nav-icon">open_in_new</span>
|
||||
<span class="nav-text">查看网站</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_logout') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">logout</span>
|
||||
<span class="nav-text">退出登录</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="sidebar-user">
|
||||
<div class="user-avatar">
|
||||
<span class="material-symbols-outlined">account_circle</span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ current_user.username }}</div>
|
||||
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% set active_page = 'change_password' %}
|
||||
{% include 'admin/components/sidebar.html' %}
|
||||
|
||||
<!-- 右侧主内容区 -->
|
||||
<div class="admin-main">
|
||||
|
||||
119
templates/admin/components/sidebar.html
Normal file
119
templates/admin/components/sidebar.html
Normal file
@@ -0,0 +1,119 @@
|
||||
<!-- 左侧菜单栏 -->
|
||||
<aside class="admin-sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<span class="material-symbols-outlined logo-icon">blur_on</span>
|
||||
<span class="logo-text">ZJPB - 自己品吧</span>
|
||||
</div>
|
||||
|
||||
<!-- 主菜单 -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">主菜单</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item {% if active_page == 'dashboard' %}active{% endif %}">
|
||||
<a href="{{ url_for('admin.index') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">dashboard</span>
|
||||
<span class="nav-text">控制台</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'site' %}active{% endif %}">
|
||||
<a href="{{ url_for('site.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">public</span>
|
||||
<span class="nav-text">网站管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'tag' %}active{% endif %}">
|
||||
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">label</span>
|
||||
<span class="nav-text">标签管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'news' %}active{% endif %}">
|
||||
<a href="{{ url_for('news.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">newspaper</span>
|
||||
<span class="nav-text">新闻管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'prompt' %}active{% endif %}">
|
||||
<a href="{{ url_for('prompttemplate.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">psychology</span>
|
||||
<span class="nav-text">Prompt管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'admin_user' %}active{% endif %}">
|
||||
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
|
||||
<span class="nav-text">管理员</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'skill' %}active{% endif %}">
|
||||
<a href="{{ url_for('skills.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">handyman</span>
|
||||
<span class="nav-text">Skills管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'api_key' %}active{% endif %}">
|
||||
<a href="{{ url_for('api_keys.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">key</span>
|
||||
<span class="nav-text">API密钥</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 系统菜单 -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">系统</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item {% if active_page == 'users' %}active{% endif %}">
|
||||
<a href="{{ url_for('admin_users') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">group</span>
|
||||
<span class="nav-text">用户管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'seo' %}active{% endif %}">
|
||||
<a href="{{ url_for('seo_tools') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">search</span>
|
||||
<span class="nav-text">SEO工具</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'batch_import' %}active{% endif %}">
|
||||
<a href="{{ url_for('batch_import') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">upload_file</span>
|
||||
<span class="nav-text">批量导入</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'change_password' %}active{% endif %}">
|
||||
<a href="{{ url_for('change_password') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">lock_reset</span>
|
||||
<span class="nav-text">修改密码</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
|
||||
<span class="material-symbols-outlined nav-icon">open_in_new</span>
|
||||
<span class="nav-text">查看网站</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_logout') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">logout</span>
|
||||
<span class="nav-text">退出登录</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="sidebar-user">
|
||||
<div class="user-avatar">
|
||||
<span class="material-symbols-outlined">account_circle</span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ current_user.username }}</div>
|
||||
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="dashboard-container">
|
||||
<div class="row">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="col-md-2-4 mb-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(0, 82, 217, 0.1); color: #0052D9;">
|
||||
<span class="material-symbols-outlined">public</span>
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="col-md-2-4 mb-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(0, 168, 112, 0.1); color: #00A870;">
|
||||
<span class="material-symbols-outlined">label</span>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="col-md-2-4 mb-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(227, 115, 24, 0.1); color: #E37318;">
|
||||
<span class="material-symbols-outlined">newspaper</span>
|
||||
@@ -40,7 +40,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="col-md-2-4 mb-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(123, 97, 255, 0.1); color: #7B61FF;">
|
||||
<span class="material-symbols-outlined">group</span>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.users_count or 0 }}</div>
|
||||
<div class="stat-label">注册用户</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2-4 mb-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(213, 73, 65, 0.1); color: #D54941;">
|
||||
<span class="material-symbols-outlined">visibility</span>
|
||||
@@ -66,6 +78,10 @@
|
||||
<span class="material-symbols-outlined">add_circle</span>
|
||||
<span>添加新工具</span>
|
||||
</a>
|
||||
<a href="{{ url_for('skills.create_view') }}" class="quick-action-btn">
|
||||
<span class="material-symbols-outlined">handyman</span>
|
||||
<span>添加Skill</span>
|
||||
</a>
|
||||
<a href="{{ url_for('tag.index_view') }}" class="quick-action-btn">
|
||||
<span class="material-symbols-outlined">label</span>
|
||||
<span>管理标签</span>
|
||||
@@ -189,6 +205,25 @@
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.col-md-2-4 {
|
||||
flex: 0 0 20%;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.col-md-2-4 {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.col-md-2-4 {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
|
||||
@@ -79,6 +79,18 @@
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">系统</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_users') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">group</span>
|
||||
<span class="nav-text">用户管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('seo_tools') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">search</span>
|
||||
<span class="nav-text">SEO工具</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('batch_import') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">upload_file</span>
|
||||
|
||||
@@ -24,101 +24,8 @@
|
||||
<link href="{{ url_for('static', filename='css/admin-actions.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="admin-sidebar-layout">
|
||||
<!-- 左侧菜单栏 -->
|
||||
<aside class="admin-sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<span class="material-symbols-outlined logo-icon">blur_on</span>
|
||||
<span class="logo-text">ZJPB - 自己品吧</span>
|
||||
</div>
|
||||
|
||||
<!-- 主菜单 -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">主菜单</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin.index') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">dashboard</span>
|
||||
<span class="nav-text">控制台</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('site.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">public</span>
|
||||
<span class="nav-text">网站管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('tag.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">label</span>
|
||||
<span class="nav-text">标签管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('news.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">newspaper</span>
|
||||
<span class="nav-text">新闻管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_users.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">admin_panel_settings</span>
|
||||
<span class="nav-text">管理员</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 系统菜单 -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">系统</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item active">
|
||||
<a href="{{ url_for('seo_tools') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">search</span>
|
||||
<span class="nav-text">SEO工具</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('batch_import') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">upload_file</span>
|
||||
<span class="nav-text">批量导入</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('change_password') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">lock_reset</span>
|
||||
<span class="nav-text">修改密码</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
|
||||
<span class="material-symbols-outlined nav-icon">open_in_new</span>
|
||||
<span class="nav-text">查看网站</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('admin_logout') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">logout</span>
|
||||
<span class="nav-text">退出登录</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="sidebar-user">
|
||||
<div class="user-avatar">
|
||||
<span class="material-symbols-outlined">account_circle</span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ current_user.username }}</div>
|
||||
<div class="user-email">{{ current_user.email or 'admin@zjpb.com' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% set active_page = 'seo' %}
|
||||
{% include 'admin/components/sidebar.html' %}
|
||||
|
||||
<!-- 右侧主内容区 -->
|
||||
<div class="admin-main">
|
||||
|
||||
@@ -55,6 +55,31 @@
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.fetch-news-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
background-color: #e6a23c;
|
||||
color: white;
|
||||
}
|
||||
.fetch-news-btn:hover {
|
||||
background-color: #cf9236;
|
||||
}
|
||||
.news-status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
.news-status.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.news-status.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.auto-fetch-btn .loading-icon, .generate-tags-btn .loading-icon {
|
||||
display: none;
|
||||
}
|
||||
@@ -637,6 +662,137 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
featuresStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 在 News Keywords 字段后添加"获取新闻"按钮
|
||||
const newsKeywordsField = document.querySelector('input[name="news_keywords"]');
|
||||
if (newsKeywordsField) {
|
||||
// 创建获取新闻按钮
|
||||
const fetchNewsBtn = document.createElement('button');
|
||||
fetchNewsBtn.type = 'button';
|
||||
fetchNewsBtn.className = 'btn fetch-news-btn';
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
|
||||
const newsStatusDiv = document.createElement('div');
|
||||
newsStatusDiv.className = 'news-status';
|
||||
|
||||
newsKeywordsField.parentNode.appendChild(fetchNewsBtn);
|
||||
newsKeywordsField.parentNode.appendChild(newsStatusDiv);
|
||||
|
||||
// 获取当前网站code(创建时没有code,需要保存后才有)
|
||||
// 这里使用一个提示
|
||||
fetchNewsBtn.addEventListener('click', function() {
|
||||
showNewsStatus('请先保存网站基本信息,保存后再点击此按钮获取新闻', 'error');
|
||||
});
|
||||
|
||||
function showNewsStatus(message, type) {
|
||||
newsStatusDiv.textContent = message;
|
||||
newsStatusDiv.className = 'news-status ' + type;
|
||||
newsStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载后,如果有已保存的网站code,显示获取新闻按钮
|
||||
// 从URL中提取site_id来判断是否是编辑模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const view = urlParams.get('view');
|
||||
const siteId = urlParams.get('id');
|
||||
|
||||
// 如果是编辑模式,添加获取新闻功能
|
||||
if (siteId) {
|
||||
const newsKeywordsFieldEdit = document.querySelector('input[name="news_keywords"]');
|
||||
const existingBtn = document.querySelector('.fetch-news-btn');
|
||||
|
||||
if (newsKeywordsFieldEdit && !existingBtn) {
|
||||
// 创建获取新闻按钮(编辑模式)
|
||||
const fetchNewsBtn = document.createElement('button');
|
||||
fetchNewsBtn.type = 'button';
|
||||
fetchNewsBtn.className = 'btn fetch-news-btn';
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
|
||||
const newsStatusDiv = document.createElement('div');
|
||||
newsStatusDiv.className = 'news-status';
|
||||
|
||||
newsKeywordsFieldEdit.parentNode.appendChild(fetchNewsBtn);
|
||||
newsKeywordsFieldEdit.parentNode.appendChild(newsStatusDiv);
|
||||
|
||||
// 获取网站code
|
||||
let siteCode = '';
|
||||
const codeField = document.querySelector('input[name="code"]');
|
||||
if (codeField) {
|
||||
siteCode = codeField.value;
|
||||
}
|
||||
|
||||
fetchNewsBtn.addEventListener('click', function() {
|
||||
if (!siteCode) {
|
||||
// 尝试从其他方式获取code
|
||||
const urlMatch = window.location.pathname.match(/\/admin\/site\/edit\/(\d+)/);
|
||||
if (urlMatch) {
|
||||
// 通过网站ID获取code(需要API支持,这里先提示)
|
||||
showNewsStatus('正在获取网站信息...', 'success');
|
||||
|
||||
// 调用API获取网站信息
|
||||
fetch('/admin/api/site/' + urlMatch[1], {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.code) {
|
||||
fetchNews(siteCode);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showNewsStatus('无法获取网站编码,请确保网站已保存', 'error');
|
||||
});
|
||||
} else {
|
||||
showNewsStatus('请先保存网站', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fetchNews(siteCode);
|
||||
});
|
||||
|
||||
function fetchNews(code) {
|
||||
// 显示加载状态
|
||||
fetchNewsBtn.disabled = true;
|
||||
fetchNewsBtn.classList.add('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="loading-icon">↻</span> 获取中...';
|
||||
newsStatusDiv.style.display = 'none';
|
||||
|
||||
// 调用后台获取新闻API
|
||||
fetch('/api/admin/fetch-news/' + code, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNewsStatus('✓ ' + data.message, 'success');
|
||||
} else {
|
||||
showNewsStatus('✗ ' + (data.error || '获取失败'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showNewsStatus('✗ 网络请求失败', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
fetchNewsBtn.disabled = false;
|
||||
fetchNewsBtn.classList.remove('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
});
|
||||
}
|
||||
|
||||
function showNewsStatus(message, type) {
|
||||
newsStatusDiv.textContent = message;
|
||||
newsStatusDiv.className = 'news-status ' + type;
|
||||
newsStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -55,6 +55,31 @@
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.fetch-news-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
background-color: #e6a23c;
|
||||
color: white;
|
||||
}
|
||||
.fetch-news-btn:hover {
|
||||
background-color: #cf9236;
|
||||
}
|
||||
.news-status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
.news-status.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.news-status.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.auto-fetch-btn .loading-icon, .generate-tags-btn .loading-icon {
|
||||
display: none;
|
||||
}
|
||||
@@ -658,6 +683,88 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
featuresStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 在 News Keywords 字段后添加"获取新闻"按钮
|
||||
const newsKeywordsField = document.querySelector('input[name="news_keywords"]');
|
||||
if (newsKeywordsField) {
|
||||
// 检查是否已经添加过按钮
|
||||
if (document.querySelector('.fetch-news-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建获取新闻按钮
|
||||
const fetchNewsBtn = document.createElement('button');
|
||||
fetchNewsBtn.type = 'button';
|
||||
fetchNewsBtn.className = 'btn fetch-news-btn';
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
|
||||
const newsStatusDiv = document.createElement('div');
|
||||
newsStatusDiv.className = 'news-status';
|
||||
|
||||
newsKeywordsField.parentNode.appendChild(fetchNewsBtn);
|
||||
newsKeywordsField.parentNode.appendChild(newsStatusDiv);
|
||||
|
||||
// 从URL中获取网站ID
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const siteId = urlParams.get('id');
|
||||
|
||||
fetchNewsBtn.addEventListener('click', function() {
|
||||
if (!siteId) {
|
||||
showNewsStatus('无法获取网站ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
fetchNewsBtn.disabled = true;
|
||||
fetchNewsBtn.classList.add('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="loading-icon">↻</span> 获取中...';
|
||||
newsStatusDiv.style.display = 'none';
|
||||
|
||||
// 先获取网站信息(包含code)
|
||||
fetch('/admin/site/api/' + siteId, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data && data.code) {
|
||||
// 获取到code后再调用获取新闻API
|
||||
return fetch('/api/admin/fetch-news/' + data.code, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('无法获取网站信息');
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNewsStatus('✓ ' + data.message, 'success');
|
||||
} else {
|
||||
showNewsStatus('✗ ' + (data.error || '获取失败'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showNewsStatus('✗ 网络请求失败: ' + error.message, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
fetchNewsBtn.disabled = false;
|
||||
fetchNewsBtn.classList.remove('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
});
|
||||
});
|
||||
|
||||
function showNewsStatus(message, type) {
|
||||
newsStatusDiv.textContent = message;
|
||||
newsStatusDiv.className = 'news-status ' + type;
|
||||
newsStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
224
templates/admin/skill/create.html
Normal file
224
templates/admin/skill/create.html
Normal file
@@ -0,0 +1,224 @@
|
||||
{% extends 'admin/model/create.html' %}
|
||||
|
||||
{% block tail %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.auto-fetch-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.fetch-status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
.fetch-status.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.fetch-status.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.logo-preview {
|
||||
margin-top: 10px;
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
display: none;
|
||||
}
|
||||
.logo-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<h2>添加 Skill</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<div class="form-group">
|
||||
<label for="github_url">GitHub 仓库 URL <span style="color: #999; font-weight: normal;">(可选,填写后自动获取信息)</span></label>
|
||||
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||
<input type="text" class="form-control" id="github_url" placeholder="例如: https://github.com/anthropics/claude-code" style="flex: 1;">
|
||||
<button type="button" class="auto-fetch-btn btn btn-info" id="fetchSkillBtn">
|
||||
<span class="material-symbols-outlined" style="vertical-align: middle;">auto_awesome</span>
|
||||
自动识别
|
||||
</button>
|
||||
</div>
|
||||
<div class="fetch-status" id="fetchStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Skill 名称 <span style="color: #e74c3c;">*</span></label>
|
||||
<input type="text" class="form-control" name="name" id="name" required placeholder="例如: PDF Helper">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="short_desc">简短描述</label>
|
||||
<input type="text" class="form-control" name="short_desc" id="short_desc" placeholder="一句话描述这个 Skill 的用途">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">详细介绍</label>
|
||||
<textarea class="form-control" name="description" id="description" rows="4" placeholder="详细介绍这个 Skill 的功能和使用场景"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="logo">Logo 图片路径</label>
|
||||
<input type="text" class="form-control" name="logo" id="logo" placeholder="/static/logos/xxx.png">
|
||||
<div class="logo-preview" id="logoPreview">
|
||||
<img id="logoImg" src="" alt="Logo预览">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="github_url_field">GitHub 链接</label>
|
||||
<input type="text" class="form-control" name="github_url" id="github_url_field" placeholder="https://github.com/...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="source_type">来源类型</label>
|
||||
<select class="form-control" name="source_type" id="source_type">
|
||||
<option value="manual" selected>手动添加</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="source_repo">来源仓库</label>
|
||||
<input type="text" class="form-control" name="source_repo" id="source_repo" placeholder="owner/repo">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="usage">包含内容 / 应用场景</label>
|
||||
<textarea class="form-control" name="usage" id="usage" rows="4" placeholder="这个 skills 仓库主要包含哪些内容、适合什么场景"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="examples">来源链接 / 示例</label>
|
||||
<textarea class="form-control" name="examples" id="examples" rows="4" placeholder="主要来源链接或示例链接"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="is_active" id="is_active" checked>
|
||||
<label class="form-check-label" for="is_active">启用</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="is_featured" id="is_featured">
|
||||
<label class="form-check-label" for="is_featured">推荐</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sort_order">排序权重</label>
|
||||
<input type="number" class="form-control" name="sort_order" id="sort_order" value="0" placeholder="数字越大越靠前">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<a href="{{ url_for('skills.index_view') }}" class="btn btn-secondary">取消</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block tail2 %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fetchBtn = document.getElementById('fetchSkillBtn');
|
||||
const urlInput = document.getElementById('github_url');
|
||||
const statusDiv = document.getElementById('fetchStatus');
|
||||
const logoInput = document.getElementById('logo');
|
||||
const logoPreview = document.getElementById('logoPreview');
|
||||
const logoImg = document.getElementById('logoImg');
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = 'fetch-status ' + type;
|
||||
statusDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
if (logoInput) {
|
||||
logoInput.addEventListener('input', function() {
|
||||
if (this.value) {
|
||||
logoImg.src = this.value;
|
||||
logoPreview.style.display = 'block';
|
||||
} else {
|
||||
logoPreview.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (fetchBtn && urlInput) {
|
||||
fetchBtn.addEventListener('click', function() {
|
||||
const url = urlInput.value.trim();
|
||||
|
||||
if (!url) {
|
||||
showStatus('请先输入 GitHub 仓库 URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.includes('github.com')) {
|
||||
showStatus('请输入有效的 GitHub 仓库 URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchBtn.disabled = true;
|
||||
statusDiv.style.display = 'none';
|
||||
|
||||
fetch('/api/fetch-skill-info', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url: url })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
showStatus('✗ ' + (data.message || '获取失败,请手动填写'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const nameField = document.querySelector('input[name="name"]');
|
||||
const shortDescField = document.querySelector('input[name="short_desc"]');
|
||||
const descriptionField = document.querySelector('textarea[name="description"]');
|
||||
const githubUrlField = document.querySelector('input[name="github_url"]');
|
||||
const sourceRepoField = document.querySelector('input[name="source_repo"]');
|
||||
const sourceTypeField = document.querySelector('select[name="source_type"]');
|
||||
const usageField = document.querySelector('textarea[name="usage"]');
|
||||
const examplesField = document.querySelector('textarea[name="examples"]');
|
||||
|
||||
if (nameField && data.data.name) nameField.value = data.data.name;
|
||||
if (shortDescField && data.data.short_desc) shortDescField.value = data.data.short_desc.substring(0, 100);
|
||||
if (descriptionField && data.data.description) descriptionField.value = data.data.description;
|
||||
if (githubUrlField && data.data.github_url) githubUrlField.value = data.data.github_url;
|
||||
if (sourceRepoField && data.data.source_repo) sourceRepoField.value = data.data.source_repo;
|
||||
if (sourceTypeField && data.data.source_type) sourceTypeField.value = data.data.source_type;
|
||||
if (usageField && data.data.usage) usageField.value = data.data.usage;
|
||||
if (examplesField && data.data.examples) examplesField.value = data.data.examples;
|
||||
|
||||
showStatus('✓ Skill 信息获取成功!已自动填充标题、简介、应用场景和来源链接', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showStatus('✗ 网络请求失败,请手动填写', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
fetchBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
884
templates/admin/users/detail.html
Normal file
884
templates/admin/users/detail.html
Normal file
@@ -0,0 +1,884 @@
|
||||
{% extends 'admin/master.html' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="user-detail-container">
|
||||
<!-- 返回按钮 -->
|
||||
<div class="back-nav">
|
||||
<a href="{{ url_for('admin_users') }}" class="btn-back">
|
||||
<span class="material-symbols-outlined">arrow_back</span>
|
||||
返回用户列表
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 用户基本信息卡片 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">基本信息</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="user-profile">
|
||||
<div class="user-avatar">
|
||||
{% if user.avatar %}
|
||||
<img src="{{ user.avatar }}" alt="{{ user.username }}">
|
||||
{% else %}
|
||||
<div class="avatar-placeholder">
|
||||
<span class="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-info-grid">
|
||||
<div class="info-item">
|
||||
<label>用户ID</label>
|
||||
<div class="info-value">{{ user.id }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>用户名</label>
|
||||
<div class="info-value">
|
||||
<strong>{{ user.username }}</strong>
|
||||
<button class="btn-icon" onclick="showEditUsernameModal()" title="修改昵称">
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>邮箱</label>
|
||||
<div class="info-value">
|
||||
{% if user.email %}
|
||||
{{ user.email }}
|
||||
{% if user.email_verified %}
|
||||
<span class="badge badge-success-sm">
|
||||
<span class="material-symbols-outlined">verified</span>
|
||||
已验证
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning-sm">未验证</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>注册时间</label>
|
||||
<div class="info-value">{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '-' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>最后登录</label>
|
||||
<div class="info-value">{{ user.last_login.strftime('%Y-%m-%d %H:%M:%S') if user.last_login else '从未登录' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>账户状态</label>
|
||||
<div class="info-value">
|
||||
{% if user.is_active %}
|
||||
<span class="badge badge-success">正常</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">已禁用</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>个人简介</label>
|
||||
<div class="info-value">{{ user.bio if user.bio else '暂无' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>资料公开</label>
|
||||
<div class="info-value">
|
||||
{% if user.is_public_profile %}
|
||||
<span class="badge badge-info-sm">公开</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary-sm">私密</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理操作卡片 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">管理操作</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-warning" onclick="showResetPasswordModal()">
|
||||
<span class="material-symbols-outlined">lock_reset</span>
|
||||
重置密码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收藏统计卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(0, 82, 217, 0.1); color: #0052D9;">
|
||||
<span class="material-symbols-outlined">bookmark</span>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ collections_count }}</div>
|
||||
<div class="stat-label">收藏的工具</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(0, 168, 112, 0.1); color: #00A870;">
|
||||
<span class="material-symbols-outlined">folder</span>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ folders_count }}</div>
|
||||
<div class="stat-label">收藏分组</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收藏分组列表 -->
|
||||
{% if folders %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">收藏分组 ({{ folders_count }})</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="folders-grid">
|
||||
{% for folder in folders %}
|
||||
<div class="folder-item">
|
||||
<div class="folder-icon">{{ folder.icon }}</div>
|
||||
<div class="folder-info">
|
||||
<div class="folder-name">{{ folder.name }}</div>
|
||||
<div class="folder-meta">
|
||||
<span>{{ folder.collections.count() }} 个工具</span>
|
||||
{% if folder.is_public %}
|
||||
<span class="badge badge-info-sm">公开</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 最近收藏 -->
|
||||
{% if recent_collections %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">最近收藏 (最多显示10条)</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工具名称</th>
|
||||
<th>所属分组</th>
|
||||
<th>收藏时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for collection in recent_collections %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="site-info">
|
||||
{% if collection.site.logo %}
|
||||
<img src="{{ collection.site.logo }}" alt="{{ collection.site.name }}" class="site-logo">
|
||||
{% endif %}
|
||||
<a href="/site/{{ collection.site.code }}" target="_blank">{{ collection.site.name }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if collection.folder %}
|
||||
<span class="folder-badge">{{ collection.folder.icon }} {{ collection.folder.name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未分组</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ collection.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<div id="resetPasswordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5>重置用户密码</h5>
|
||||
<button class="close-btn" onclick="closeResetPasswordModal()">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted mb-3">为用户 <strong>{{ user.username }}</strong> 设置新密码</p>
|
||||
<div class="form-group">
|
||||
<label>新密码 <span class="text-danger">*</span></label>
|
||||
<input type="password" id="newPassword" class="form-control" placeholder="至少6位字符">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认密码 <span class="text-danger">*</span></label>
|
||||
<input type="password" id="confirmPassword" class="form-control" placeholder="再次输入新密码">
|
||||
</div>
|
||||
<div id="resetPasswordError" class="alert alert-danger" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeResetPasswordModal()">取消</button>
|
||||
<button class="btn btn-warning" onclick="submitResetPassword()">确认重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改昵称弹窗 -->
|
||||
<div id="editUsernameModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5>修改用户昵称</h5>
|
||||
<button class="close-btn" onclick="closeEditUsernameModal()">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>当前昵称</label>
|
||||
<input type="text" class="form-control" value="{{ user.username }}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新昵称 <span class="text-danger">*</span></label>
|
||||
<input type="text" id="newUsername" class="form-control" placeholder="2-50个字符" value="{{ user.username }}">
|
||||
</div>
|
||||
<div id="editUsernameError" class="alert alert-danger" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeEditUsernameModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="submitEditUsername()">确认修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 重置密码弹窗
|
||||
function showResetPasswordModal() {
|
||||
document.getElementById('resetPasswordModal').style.display = 'flex';
|
||||
document.getElementById('newPassword').value = '';
|
||||
document.getElementById('confirmPassword').value = '';
|
||||
document.getElementById('resetPasswordError').style.display = 'none';
|
||||
}
|
||||
|
||||
function closeResetPasswordModal() {
|
||||
document.getElementById('resetPasswordModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function submitResetPassword() {
|
||||
const newPassword = document.getElementById('newPassword').value.trim();
|
||||
const confirmPassword = document.getElementById('confirmPassword').value.trim();
|
||||
const errorDiv = document.getElementById('resetPasswordError');
|
||||
|
||||
if (!newPassword) {
|
||||
errorDiv.textContent = '请输入新密码';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
errorDiv.textContent = '密码至少需要6位字符';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
errorDiv.textContent = '两次输入的密码不一致';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/admin/users/{{ user.id }}/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
closeResetPasswordModal();
|
||||
} else {
|
||||
errorDiv.textContent = data.message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
errorDiv.textContent = '操作失败,请重试';
|
||||
errorDiv.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
// 修改昵称弹窗
|
||||
function showEditUsernameModal() {
|
||||
document.getElementById('editUsernameModal').style.display = 'flex';
|
||||
document.getElementById('editUsernameError').style.display = 'none';
|
||||
}
|
||||
|
||||
function closeEditUsernameModal() {
|
||||
document.getElementById('editUsernameModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function submitEditUsername() {
|
||||
const newUsername = document.getElementById('newUsername').value.trim();
|
||||
const errorDiv = document.getElementById('editUsernameError');
|
||||
|
||||
if (!newUsername) {
|
||||
errorDiv.textContent = '请输入新昵称';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newUsername.length < 2 || newUsername.length > 50) {
|
||||
errorDiv.textContent = '昵称长度需要在2-50个字符之间';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/admin/users/{{ user.id }}/update-username', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ new_username: newUsername })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
errorDiv.textContent = data.message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
errorDiv.textContent = '操作失败,请重试';
|
||||
errorDiv.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
window.onclick = function(event) {
|
||||
const resetModal = document.getElementById('resetPasswordModal');
|
||||
const editModal = document.getElementById('editUsernameModal');
|
||||
if (event.target === resetModal) closeResetPasswordModal();
|
||||
if (event.target === editModal) closeEditUsernameModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.user-detail-container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.back-nav {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: #F5F7FA;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 4px;
|
||||
color: #606266;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: #ECF2FE;
|
||||
border-color: #0052D9;
|
||||
color: #0052D9;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-back .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: #F5F7FA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-placeholder .material-symbols-outlined {
|
||||
font-size: 48px;
|
||||
color: #C0C4CC;
|
||||
}
|
||||
|
||||
.user-info-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #F5F7FA;
|
||||
color: #0052D9;
|
||||
}
|
||||
|
||||
.btn-icon .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(0, 168, 112, 0.1);
|
||||
color: #00A870;
|
||||
}
|
||||
|
||||
.badge-success-sm {
|
||||
background: rgba(0, 168, 112, 0.1);
|
||||
color: #00A870;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.badge-success-sm .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.badge-warning-sm {
|
||||
background: rgba(227, 115, 24, 0.1);
|
||||
color: #E37318;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.badge-secondary-sm {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.badge-info-sm {
|
||||
background: rgba(0, 82, 217, 0.1);
|
||||
color: #0052D9;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
.col-md-6 {
|
||||
flex: 1;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon .material-symbols-outlined {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #E37318;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #C96316;
|
||||
}
|
||||
|
||||
.btn .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.folders-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #F5F7FA;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.folder-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.folder-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.p-0 {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background: #F5F7FA;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #F5F7FA;
|
||||
}
|
||||
|
||||
.site-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.site-info a {
|
||||
color: #0052D9;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.folder-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #F5F7FA;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.modal-header h5 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #F5F7FA;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #0052D9;
|
||||
}
|
||||
|
||||
.form-control:disabled {
|
||||
background: #F5F7FA;
|
||||
color: #909399;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #D54941;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: rgba(213, 73, 65, 0.1);
|
||||
color: #D54941;
|
||||
border: 1px solid rgba(213, 73, 65, 0.2);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #E5E5E5;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0052D9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0041A8;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
383
templates/admin/users/list.html
Normal file
383
templates/admin/users/list.html
Normal file
@@ -0,0 +1,383 @@
|
||||
{% extends 'admin/master.html' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="users-container">
|
||||
<!-- 页面标题和搜索 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h4 class="mb-1">用户管理</h4>
|
||||
<p class="text-muted mb-0">管理平台注册用户</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<form method="GET" action="{{ url_for('admin_users') }}" class="search-form">
|
||||
<div class="input-group">
|
||||
<input type="text" name="search" class="form-control" placeholder="搜索用户名或邮箱" value="{{ search }}">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="material-symbols-outlined">search</span>
|
||||
</button>
|
||||
{% if search %}
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-secondary">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>收藏统计</th>
|
||||
<th>注册时间</th>
|
||||
<th>最后登录</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<strong>{{ user.username }}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.email %}
|
||||
<div class="email-info">
|
||||
{{ user.email }}
|
||||
{% if user.email_verified %}
|
||||
<span class="badge badge-success-sm" title="邮箱已验证">
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">verified</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="stats-info">
|
||||
<span class="stat-item">
|
||||
<span class="material-symbols-outlined">bookmark</span>
|
||||
{{ user_stats[user.id].collections_count }}
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="material-symbols-outlined">folder</span>
|
||||
{{ user_stats[user.id].folders_count }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</td>
|
||||
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '从未登录' }}</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge badge-success">正常</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">已禁用</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin_user_detail', user_id=user.id) }}" class="btn btn-sm btn-primary">
|
||||
查看详情
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="pagination-container">
|
||||
<nav>
|
||||
<ul class="pagination mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin_users', page=pagination.prev_num, search=search) }}">上一页</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">上一页</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
{% if page_num == pagination.page %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin_users', page=page_num, search=search) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin_users', page=pagination.next_num, search=search) }}">下一页</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">下一页</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="pagination-info">
|
||||
共 {{ pagination.total }} 个用户,第 {{ pagination.page }} / {{ pagination.pages }} 页
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<span class="material-symbols-outlined">person_off</span>
|
||||
<p>{% if search %}未找到匹配的用户{% else %}暂无注册用户{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.users-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header h4 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
min-width: 350px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-group .form-control {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-group .btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0052D9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0041A8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #E5E5E5;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background: #F5F7FA;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #F5F7FA;
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.email-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.stat-item .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(0, 168, 112, 0.1);
|
||||
color: #00A870;
|
||||
}
|
||||
|
||||
.badge-success-sm {
|
||||
background: rgba(0, 168, 112, 0.1);
|
||||
color: #00A870;
|
||||
padding: 2px 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-item .page-link {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 4px;
|
||||
color: #606266;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
background: #0052D9;
|
||||
color: white;
|
||||
border-color: #0052D9;
|
||||
}
|
||||
|
||||
.page-item.disabled .page-link {
|
||||
color: #C0C4CC;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-item:not(.disabled):not(.active) .page-link:hover {
|
||||
background: #F5F7FA;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.empty-state .material-symbols-outlined {
|
||||
font-size: 64px;
|
||||
color: #DCDFE6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -306,6 +306,7 @@
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/#categories">Categories</a></li>
|
||||
<li><a href="/skills">Skills</a></li>
|
||||
<li><a href="/admin/login">Admin</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -329,6 +330,7 @@
|
||||
<div id="userDropdown" class="dropdown-menu" style="display: none;">
|
||||
<a href="/user/profile">👤 个人中心</a>
|
||||
<a href="/user/collections">⭐ 我的收藏</a>
|
||||
<a href="/user/change-password">🔒 修改密码</a>
|
||||
<hr>
|
||||
<a href="#" onclick="logout(event)">🚪 退出登录</a>
|
||||
</div>
|
||||
|
||||
@@ -800,9 +800,11 @@
|
||||
<span>📰</span>
|
||||
相关新闻
|
||||
</h2>
|
||||
<button id="refreshNewsBtn" class="refresh-news-btn" onclick="showCaptchaModal('{{ site.code }}')">
|
||||
<span class="refresh-icon">↻</span> <span class="btn-text">{% if has_news %}获取最新资讯{% else %}加载资讯{% endif %}</span>
|
||||
</button>
|
||||
{% if news_list %}
|
||||
<span style="font-size: 13px; color: var(--text-muted);">
|
||||
共 {{ news_list|length }} 条资讯
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="newsContainer">
|
||||
{% if news_list %}
|
||||
@@ -1036,6 +1038,7 @@ function loadNewsWithCaptcha(siteCode, captcha) {
|
||||
// 调用新闻获取API
|
||||
fetch(`/api/fetch-news/${siteCode}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin', // 确保发送session cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
|
||||
215
templates/skills/detail.html
Normal file
215
templates/skills/detail.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends 'base_new.html' %}
|
||||
|
||||
{% block title %}{{ skill.name }} - Skills | ZJPB{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<meta name="description" content="{{ skill.short_desc or (skill.description[:150] if skill.description else '') }}">
|
||||
<meta name="keywords" content="{{ skill.name }},Skills,AI工具,自己品吧,ZJPB">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ skill.name }} - Skills">
|
||||
<meta property="og:description" content="{{ skill.short_desc or skill.description[:150] }}">
|
||||
<meta property="og:url" content="{{ request.url }}">
|
||||
{% if skill.logo %}<meta property="og:image" content="{{ request.url_root.rstrip('/') }}{{ skill.logo }}">{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.skill-detail-header {
|
||||
padding: 40px 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.skill-detail-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.skill-detail-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skill-detail-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.skill-detail-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-detail-desc {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skill-detail-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.skill-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.skill-section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.skill-content-box {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.skill-content-box pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.skill-content-box code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: #24292e;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.github-link:hover {
|
||||
background: #1b1f23;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #0ea5e9;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="skill-detail-header">
|
||||
<div class="skill-detail-content">
|
||||
<a href="/skills" class="back-link" style="color: white;">
|
||||
← 返回 Skills 列表
|
||||
</a>
|
||||
<div class="skill-detail-logo">
|
||||
{% if skill.logo %}
|
||||
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
|
||||
{% else %}
|
||||
🛠️
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="skill-detail-title">{{ skill.name }}</h1>
|
||||
<p class="skill-detail-desc">{{ skill.short_desc or skill.description }}</p>
|
||||
<div class="skill-detail-meta">
|
||||
{% if skill.source_type == 'github' %}
|
||||
<span class="skill-meta-item">📦 来源: GitHub</span>
|
||||
{% if skill.source_repo %}
|
||||
<span class="skill-meta-item">{{ skill.source_repo }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="skill-meta-item">✏️ 来源: 手动添加</span>
|
||||
{% endif %}
|
||||
<span class="skill-meta-item">👁 浏览: {{ skill.view_count or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-detail-content">
|
||||
{% if skill.description %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">📖 介绍</h2>
|
||||
<div class="skill-content-box">
|
||||
{{ skill.description | replace('\n', '<br>') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if skill.usage %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">🚀 使用方法</h2>
|
||||
<div class="skill-content-box">
|
||||
{{ skill.usage | replace('\n', '<br>') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if skill.examples %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">💡 使用示例</h2>
|
||||
<div class="skill-content-box">
|
||||
<pre>{{ skill.examples }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if skill.github_url %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">🔗 GitHub</h2>
|
||||
<a href="{{ skill.github_url }}" target="_blank" class="github-link">
|
||||
<svg height="20" width="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
在 GitHub 查看
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
209
templates/skills/list.html
Normal file
209
templates/skills/list.html
Normal file
@@ -0,0 +1,209 @@
|
||||
{% extends 'base_new.html' %}
|
||||
|
||||
{% block title %}Skills - AI 工具技能库 | ZJPB{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<meta name="description" content="ZJPB Skills - 发现和使用各种 AI 工具 Skills,提升工作效率">
|
||||
<meta name="keywords" content="AI Skills,Claude Code,OpenClaw,工具技能,ZJPB">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.skills-hero {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.skills-hero h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skills-hero p {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.featured-skills {
|
||||
padding: 0 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.featured-skills h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.skill-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skill-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.skill-card-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.skill-card-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skill-card-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skill-card-desc {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.skill-card-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="skills-hero">
|
||||
<div class="hero-content">
|
||||
<h1>🛠️ Skills</h1>
|
||||
<p>发现和使用各种 AI 工具 Skills,提升工作效率</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools-section" style="max-width: 1280px; margin: 0 auto;">
|
||||
{% if featured_skills %}
|
||||
<div class="featured-skills">
|
||||
<h2>⭐ 推荐 Skills</h2>
|
||||
<div class="skills-grid">
|
||||
{% for skill in featured_skills %}
|
||||
<a href="{{ url_for('skill_detail', slug=skill.slug) }}" class="skill-card">
|
||||
<div class="skill-card-logo">
|
||||
{% if skill.logo %}
|
||||
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
|
||||
{% else %}
|
||||
🛠️
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="skill-card-name">{{ skill.name }}</div>
|
||||
<div class="skill-card-desc">{{ skill.short_desc or skill.description[:100] }}</div>
|
||||
<div class="skill-card-meta">
|
||||
{% if skill.source_type == 'github' %}
|
||||
<span class="skill-card-badge">📦 GitHub</span>
|
||||
{% else %}
|
||||
<span class="skill-card-badge">✏️ 手动添加</span>
|
||||
{% endif %}
|
||||
<span>👁 {{ skill.view_count or 0 }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="featured-skills">
|
||||
<h2>📋 所有 Skills</h2>
|
||||
{% if skills %}
|
||||
<div class="skills-grid">
|
||||
{% for skill in skills %}
|
||||
<a href="{{ url_for('skill_detail', slug=skill.slug) }}" class="skill-card">
|
||||
<div class="skill-card-logo">
|
||||
{% if skill.logo %}
|
||||
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
|
||||
{% else %}
|
||||
🛠️
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="skill-card-name">{{ skill.name }}</div>
|
||||
<div class="skill-card-desc">{{ skill.short_desc or skill.description[:100] }}</div>
|
||||
<div class="skill-card-meta">
|
||||
{% if skill.source_type == 'github' %}
|
||||
<span class="skill-card-badge">📦 GitHub</span>
|
||||
{% else %}
|
||||
<span class="skill-card-badge">✏️ 手动添加</span>
|
||||
{% endif %}
|
||||
<span>👁 {{ skill.view_count or 0 }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 60px 20px; color: #64748b;">
|
||||
<p style="font-size: 18px;">暂无 Skills</p>
|
||||
<p>请在后台添加 Skills</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<div class="pagination" style="justify-content: center; margin-top: 40px; display: flex; gap: 8px;">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="?page={{ pagination.prev_num }}" class="page-btn">上一页</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
<a href="?page={{ page_num }}" class="page-btn {% if page_num == pagination.page %}active{% endif %}">{{ page_num }}</a>
|
||||
{% else %}
|
||||
<span>...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="?page={{ pagination.next_num }}" class="page-btn">下一页</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
290
templates/user/change_password.html
Normal file
290
templates/user/change_password.html
Normal file
@@ -0,0 +1,290 @@
|
||||
{% extends 'base_new.html' %}
|
||||
|
||||
{% block title %}修改密码 - ZJPB{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.change-password-container {
|
||||
max-width: 500px;
|
||||
margin: 48px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.password-card {
|
||||
background: var(--bg-white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.password-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 40px 12px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-password:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--border-color);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="change-password-container">
|
||||
<a href="{{ url_for('user_profile') }}" class="back-link">
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">arrow_back</span>
|
||||
返回个人中心
|
||||
</a>
|
||||
|
||||
<div class="password-card">
|
||||
<h1 class="card-title">修改密码</h1>
|
||||
<p class="card-subtitle">为了您的账户安全,请定期修改密码</p>
|
||||
|
||||
<div id="alert" class="alert"></div>
|
||||
|
||||
<form id="changePasswordForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="old_password">旧密码</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input type="password" id="old_password" name="old_password" class="form-input" required>
|
||||
<button type="button" class="toggle-password" onclick="togglePassword('old_password')">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">visibility</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new_password">新密码</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="6">
|
||||
<button type="button" class="toggle-password" onclick="togglePassword('new_password')">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">visibility</span>
|
||||
</button>
|
||||
</div>
|
||||
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 4px; display: block;">至少6个字符</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="confirm_password">确认新密码</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-input" required minlength="6">
|
||||
<button type="button" class="toggle-password" onclick="togglePassword('confirm_password')">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">visibility</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" id="submitBtn">
|
||||
修改密码
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 密码可见性切换
|
||||
function togglePassword(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const button = input.nextElementSibling;
|
||||
const icon = button.querySelector('.material-symbols-outlined');
|
||||
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.textContent = 'visibility_off';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.textContent = 'visibility';
|
||||
}
|
||||
}
|
||||
|
||||
// 显示提示消息
|
||||
function showAlert(message, type) {
|
||||
const alert = document.getElementById('alert');
|
||||
alert.textContent = message;
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.style.display = 'block';
|
||||
|
||||
// 3秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
alert.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
document.getElementById('changePasswordForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const oldPassword = document.getElementById('old_password').value;
|
||||
const newPassword = document.getElementById('new_password').value;
|
||||
const confirmPassword = document.getElementById('confirm_password').value;
|
||||
|
||||
// 前端验证
|
||||
if (newPassword !== confirmPassword) {
|
||||
showAlert('两次输入的新密码不一致', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showAlert('新密码长度至少6位', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldPassword === newPassword) {
|
||||
showAlert('新密码不能与旧密码相同', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用按钮
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '修改中...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/change-password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showAlert(data.message, 'success');
|
||||
// 清空表单
|
||||
document.getElementById('changePasswordForm').reset();
|
||||
// 2秒后跳转到个人中心
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ url_for("user_profile") }}';
|
||||
}, 2000);
|
||||
} else {
|
||||
showAlert(data.message, 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '修改密码';
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('网络错误,请稍后重试', 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '修改密码';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -193,6 +193,7 @@
|
||||
<ul class="nav-menu">
|
||||
<li><a href="/user/profile" class="active">👤 个人资料</a></li>
|
||||
<li><a href="/user/collections">⭐ 我的收藏</a></li>
|
||||
<li><a href="/user/change-password">🔒 修改密码</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -213,6 +214,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱管理 -->
|
||||
<div class="recent-section" style="margin-bottom: 32px;">
|
||||
<h2>邮箱管理</h2>
|
||||
<div style="background: white; padding: 20px; border: 1px solid var(--border-color); border-radius: var(--radius-md);">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
|
||||
<div>
|
||||
<div style="font-size: 14px; color: var(--text-secondary); margin-bottom: 4px;">当前邮箱</div>
|
||||
<div style="font-size: 16px; font-weight: 600;" id="currentEmail">
|
||||
{{ current_user.email or '未绑定' }}
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.email %}
|
||||
<div>
|
||||
{% if current_user.email_verified %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; background: #d1fae5; color: #065f46; border-radius: 12px; font-size: 12px;">
|
||||
<span class="material-symbols-outlined" style="font-size: 16px;">check_circle</span>
|
||||
已验证
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; background: #fef3c7; color: #92400e; border-radius: 12px; font-size: 12px;">
|
||||
<span class="material-symbols-outlined" style="font-size: 16px;">warning</span>
|
||||
未验证
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button onclick="showEmailModal()" style="padding: 8px 16px; background: var(--primary-blue); color: white; border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 14px;">
|
||||
{{ '修改邮箱' if current_user.email else '绑定邮箱' }}
|
||||
</button>
|
||||
{% if current_user.email and not current_user.email_verified %}
|
||||
<button onclick="sendVerifyEmail()" id="verifyBtn" style="padding: 8px 16px; background: #f59e0b; color: white; border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 14px;">
|
||||
发送验证邮件
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近收藏 -->
|
||||
<div class="recent-section">
|
||||
<h2>最近收藏</h2>
|
||||
@@ -242,4 +284,146 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱管理弹窗 -->
|
||||
<div id="emailModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||
<div style="background: white; border-radius: var(--radius-lg); padding: 32px; max-width: 500px; width: 90%;">
|
||||
<h2 style="font-size: 20px; font-weight: 700; margin-bottom: 24px;">{{ '修改邮箱' if current_user.email else '绑定邮箱' }}</h2>
|
||||
|
||||
<div id="emailAlert" style="display: none; padding: 12px; border-radius: var(--radius-md); margin-bottom: 16px; font-size: 14px;"></div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-size: 14px; font-weight: 600; margin-bottom: 8px;">邮箱地址</label>
|
||||
<input type="email" id="emailInput" placeholder="请输入邮箱地址" style="width: 100%; padding: 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button onclick="hideEmailModal()" style="padding: 10px 20px; background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-md); cursor: pointer;">
|
||||
取消
|
||||
</button>
|
||||
<button onclick="updateEmail()" id="emailSubmitBtn" style="padding: 10px 20px; background: var(--primary-blue); color: white; border: none; border-radius: var(--radius-md); cursor: pointer;">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 显示邮箱弹窗
|
||||
function showEmailModal() {
|
||||
const modal = document.getElementById('emailModal');
|
||||
const input = document.getElementById('emailInput');
|
||||
input.value = '{{ current_user.email or "" }}';
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 隐藏邮箱弹窗
|
||||
function hideEmailModal() {
|
||||
const modal = document.getElementById('emailModal');
|
||||
modal.style.display = 'none';
|
||||
document.getElementById('emailAlert').style.display = 'none';
|
||||
}
|
||||
|
||||
// 显示弹窗提示
|
||||
function showEmailAlert(message, type) {
|
||||
const alert = document.getElementById('emailAlert');
|
||||
alert.textContent = message;
|
||||
alert.style.display = 'block';
|
||||
|
||||
if (type === 'success') {
|
||||
alert.style.background = '#d1fae5';
|
||||
alert.style.color = '#065f46';
|
||||
alert.style.border = '1px solid #6ee7b7';
|
||||
} else {
|
||||
alert.style.background = '#fee2e2';
|
||||
alert.style.color = '#991b1b';
|
||||
alert.style.border = '1px solid #fca5a5';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新邮箱
|
||||
async function updateEmail() {
|
||||
const email = document.getElementById('emailInput').value.trim();
|
||||
const submitBtn = document.getElementById('emailSubmitBtn');
|
||||
|
||||
if (!email) {
|
||||
showEmailAlert('请输入邮箱地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
if (!emailPattern.test(email)) {
|
||||
showEmailAlert('邮箱格式不正确', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '提交中...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/email', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: email })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showEmailAlert(data.message, 'success');
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
showEmailAlert(data.message, 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '确定';
|
||||
}
|
||||
} catch (error) {
|
||||
showEmailAlert('网络错误,请稍后重试', 'error');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '确定';
|
||||
}
|
||||
}
|
||||
|
||||
// 发送验证邮件
|
||||
async function sendVerifyEmail() {
|
||||
const btn = document.getElementById('verifyBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/send-verify-email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
} else {
|
||||
alert(data.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证邮件';
|
||||
}
|
||||
} catch (error) {
|
||||
alert('网络错误,请稍后重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证邮件';
|
||||
}
|
||||
}
|
||||
|
||||
// 点击弹窗外部关闭
|
||||
document.getElementById('emailModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
hideEmailModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
41
update.sh
Normal file
41
update.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# ZJPB 更新部署脚本
|
||||
# 用法: ./update.sh
|
||||
# 功能: git pull 拉取最新代码,然后重启服务
|
||||
|
||||
set -e
|
||||
|
||||
APP_DIR="/opt/1panel/apps/zjpb"
|
||||
SERVICE_NAME="zjpb"
|
||||
|
||||
echo "=============================="
|
||||
echo " ZJPB 更新部署"
|
||||
echo "=============================="
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 1. 拉取最新代码
|
||||
echo "[1/3] 拉取最新代码..."
|
||||
git pull
|
||||
echo " 完成"
|
||||
|
||||
# 2. 重启服务
|
||||
echo "[2/3] 重启服务..."
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
echo " 完成"
|
||||
|
||||
# 3. 验证服务状态
|
||||
echo "[3/3] 验证服务状态..."
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
echo " 服务运行正常"
|
||||
else
|
||||
echo " [错误] 服务启动失败,查看日志:"
|
||||
journalctl -u "$SERVICE_NAME" -n 20 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=============================="
|
||||
echo " 部署完成"
|
||||
echo "=============================="
|
||||
73
utils/email_sender.py
Normal file
73
utils/email_sender.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
邮件发送工具模块
|
||||
支持发送验证邮件、通知邮件等
|
||||
"""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.header import Header
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailSender:
|
||||
"""邮件发送器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化邮件配置"""
|
||||
self.smtp_server = os.environ.get('SMTP_SERVER', 'smtp.gmail.com')
|
||||
self.smtp_port = int(os.environ.get('SMTP_PORT', '587'))
|
||||
self.smtp_user = os.environ.get('SMTP_USER', '')
|
||||
self.smtp_password = os.environ.get('SMTP_PASSWORD', '')
|
||||
self.from_email = os.environ.get('FROM_EMAIL', self.smtp_user)
|
||||
self.from_name = os.environ.get('FROM_NAME', 'ZJPB')
|
||||
|
||||
def send_email(self, to_email, subject, html_content, text_content=None):
|
||||
"""
|
||||
发送邮件
|
||||
|
||||
Args:
|
||||
to_email: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_content: HTML格式邮件内容
|
||||
text_content: 纯文本格式邮件内容(可选)
|
||||
|
||||
Returns:
|
||||
bool: 发送是否成功
|
||||
"""
|
||||
try:
|
||||
# 检查配置
|
||||
if not self.smtp_user or not self.smtp_password:
|
||||
logger.error('邮件配置不完整,请设置SMTP_USER和SMTP_PASSWORD环境变量')
|
||||
return False
|
||||
|
||||
# 创建邮件对象
|
||||
message = MIMEMultipart('alternative')
|
||||
message['From'] = f'{self.from_name} <{self.from_email}>'
|
||||
message['To'] = to_email
|
||||
message['Subject'] = Header(subject, 'utf-8')
|
||||
|
||||
# 添加纯文本内容
|
||||
if text_content:
|
||||
text_part = MIMEText(text_content, 'plain', 'utf-8')
|
||||
message.attach(text_part)
|
||||
|
||||
# 添加HTML内容
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
message.attach(html_part)
|
||||
|
||||
# 连接SMTP服务器并发送
|
||||
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||
server.starttls() # 启用TLS加密
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.send_message(message)
|
||||
|
||||
logger.info(f'邮件发送成功: {to_email}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'邮件发送失败: {str(e)}')
|
||||
return False
|
||||
@@ -6,6 +6,7 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urljoin, urlparse
|
||||
import os
|
||||
import re
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
@@ -54,6 +55,243 @@ class WebsiteFetcher:
|
||||
print(f"抓取网站信息失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def fetch_skill_info(self, url):
|
||||
"""
|
||||
抓取 Skill 信息(GitHub 仓库)
|
||||
|
||||
Args:
|
||||
url: GitHub 仓库URL
|
||||
|
||||
Returns:
|
||||
dict: 包含name, short_desc, github_url, source_repo的字典
|
||||
"""
|
||||
try:
|
||||
# 解析 GitHub URL
|
||||
# 支持格式: https://github.com/user/repo 或 https://github.com/user/repo/tree/main
|
||||
parsed = urlparse(url)
|
||||
if 'github.com' not in parsed.netloc:
|
||||
return None
|
||||
|
||||
path_parts = [p for p in parsed.path.split('/') if p]
|
||||
if len(path_parts) < 2:
|
||||
return None
|
||||
|
||||
owner = path_parts[0]
|
||||
repo = path_parts[1].replace('.git', '')
|
||||
|
||||
# 获取仓库信息
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
||||
response = requests.get(api_url, headers=self.headers, timeout=self.timeout)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
repo_info = response.json()
|
||||
|
||||
# 获取 .claude 目录下的 skills
|
||||
skills_url = f"https://api.github.com/repos/{owner}/{repo}/contents/.claude/skills"
|
||||
skills_response = requests.get(skills_url, headers=self.headers, timeout=self.timeout)
|
||||
|
||||
skills = []
|
||||
if skills_response.status_code == 200:
|
||||
skills_data = skills_response.json()
|
||||
for item in skills_data:
|
||||
if item.get('type') == 'file' and item['name'].endswith('.md'):
|
||||
# 获取 skill 文件内容
|
||||
skill_content = requests.get(item['download_url'], headers=self.headers, timeout=self.timeout)
|
||||
if skill_content.status_code == 200:
|
||||
skill_info = self._parse_skill_file(
|
||||
item['name'],
|
||||
skill_content.text,
|
||||
item.get('html_url') or item.get('download_url') or ''
|
||||
)
|
||||
if skill_info:
|
||||
skills.append(skill_info)
|
||||
|
||||
summary = self._summarize_skills(repo_info, skills)
|
||||
|
||||
return {
|
||||
'name': summary.get('name') or repo_info.get('name', ''),
|
||||
'short_desc': summary.get('short_desc') or repo_info.get('description', ''),
|
||||
'description': summary.get('description', ''),
|
||||
'github_url': url,
|
||||
'source_repo': f"{owner}/{repo}",
|
||||
'usage': summary.get('usage', ''),
|
||||
'examples': summary.get('examples', ''),
|
||||
'skills': skills
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"抓取 Skill 信息失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def _parse_skill_file(self, filename, content, source_url=''):
|
||||
"""解析 skill 文件内容"""
|
||||
try:
|
||||
lines = [line.rstrip() for line in content.strip().split('\n')]
|
||||
fallback_name = filename.replace('.md', '')
|
||||
title = fallback_name
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('# '):
|
||||
title = line[2:].strip()
|
||||
break
|
||||
|
||||
sections = self._extract_markdown_sections(lines)
|
||||
|
||||
desc = self._pick_section(
|
||||
sections,
|
||||
['描述', '简介', 'description', 'overview', 'summary', 'what it does']
|
||||
)
|
||||
use_cases = self._pick_section(
|
||||
sections,
|
||||
['应用场景', '适用场景', '使用场景', 'use cases', 'when to use', 'use case']
|
||||
)
|
||||
usage = self._pick_section(
|
||||
sections,
|
||||
['使用方法', '用法', 'usage', 'how to use', 'quick start']
|
||||
)
|
||||
examples = self._pick_section(
|
||||
sections,
|
||||
['示例', 'examples', 'example']
|
||||
)
|
||||
|
||||
if not desc:
|
||||
desc = self._first_paragraph(lines)
|
||||
|
||||
content_outline = self._extract_outline(sections)
|
||||
|
||||
return {
|
||||
'name': title,
|
||||
'description': self._clean_text(desc)[:240],
|
||||
'use_cases': self._clean_text(use_cases)[:240],
|
||||
'usage': self._clean_text(usage)[:600],
|
||||
'examples': self._clean_text(examples)[:600],
|
||||
'content_outline': content_outline,
|
||||
'source_url': source_url
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_markdown_sections(self, lines):
|
||||
sections = {}
|
||||
current = '__intro__'
|
||||
buffer = []
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if line.startswith('##'):
|
||||
sections[current] = '\n'.join(buffer).strip()
|
||||
current = re.sub(r'^#+\s*', '', line).strip().lower()
|
||||
buffer = []
|
||||
else:
|
||||
buffer.append(raw_line)
|
||||
|
||||
sections[current] = '\n'.join(buffer).strip()
|
||||
return sections
|
||||
|
||||
def _pick_section(self, sections, keywords):
|
||||
for key, value in sections.items():
|
||||
low = key.lower()
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in low and value.strip():
|
||||
return value.strip()
|
||||
return ''
|
||||
|
||||
def _first_paragraph(self, lines):
|
||||
paragraph = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith('#'):
|
||||
if paragraph:
|
||||
break
|
||||
continue
|
||||
if stripped.startswith('- ') or stripped.startswith('* ') or stripped.startswith('```'):
|
||||
continue
|
||||
paragraph.append(stripped)
|
||||
if len(' '.join(paragraph)) > 220:
|
||||
break
|
||||
return ' '.join(paragraph)
|
||||
|
||||
def _extract_outline(self, sections):
|
||||
outlines = []
|
||||
for key in sections.keys():
|
||||
if key != '__intro__':
|
||||
clean_key = key.strip()
|
||||
if clean_key:
|
||||
outlines.append(clean_key)
|
||||
return outlines[:8]
|
||||
|
||||
def _clean_text(self, text):
|
||||
if not text:
|
||||
return ''
|
||||
text = re.sub(r'```.*?```', '', text, flags=re.S)
|
||||
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||
text = re.sub(r'\[(.*?)\]\((.*?)\)', r'\1', text)
|
||||
text = re.sub(r'^[-*]\s+', '', text, flags=re.M)
|
||||
text = re.sub(r'\n{2,}', '\n', text)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text.strip()
|
||||
|
||||
def _summarize_skills(self, repo_info, skills):
|
||||
if not skills:
|
||||
return {
|
||||
'name': repo_info.get('name', ''),
|
||||
'short_desc': repo_info.get('description', ''),
|
||||
'description': repo_info.get('description', ''),
|
||||
'usage': '',
|
||||
'examples': ''
|
||||
}
|
||||
|
||||
skill_names = [s['name'] for s in skills if s.get('name')]
|
||||
use_cases = [s['use_cases'] for s in skills if s.get('use_cases')]
|
||||
descs = [s['description'] for s in skills if s.get('description')]
|
||||
outlines = []
|
||||
for s in skills:
|
||||
outlines.extend(s.get('content_outline', []))
|
||||
|
||||
repo_desc = (repo_info.get('description') or '').strip()
|
||||
short_desc = repo_desc or (descs[0] if descs else '')
|
||||
|
||||
parts = []
|
||||
if repo_desc:
|
||||
parts.append(f"仓库简介:{repo_desc}")
|
||||
if skill_names:
|
||||
shown = '、'.join(skill_names[:5])
|
||||
more = f" 等 {len(skill_names)} 个 skill" if len(skill_names) > 5 else ''
|
||||
parts.append(f"包含内容:{shown}{more}")
|
||||
if use_cases:
|
||||
parts.append(f"应用场景:{use_cases[0]}")
|
||||
elif descs:
|
||||
parts.append(f"应用场景:{descs[0]}")
|
||||
if outlines:
|
||||
uniq = []
|
||||
for item in outlines:
|
||||
if item not in uniq:
|
||||
uniq.append(item)
|
||||
parts.append(f"文档涵盖:{'、'.join(uniq[:6])}")
|
||||
|
||||
usage_lines = []
|
||||
for s in skills[:5]:
|
||||
line = f"- {s['name']}"
|
||||
if s.get('use_cases'):
|
||||
line += f":{s['use_cases']}"
|
||||
elif s.get('description'):
|
||||
line += f":{s['description']}"
|
||||
usage_lines.append(line)
|
||||
|
||||
examples_lines = []
|
||||
for s in skills[:3]:
|
||||
if s.get('source_url'):
|
||||
examples_lines.append(f"{s['name']}:{s['source_url']}")
|
||||
|
||||
return {
|
||||
'name': repo_info.get('name', ''),
|
||||
'short_desc': short_desc[:180],
|
||||
'description': '\n'.join(parts)[:1200],
|
||||
'usage': '\n'.join(usage_lines)[:1200],
|
||||
'examples': '\n'.join(examples_lines)[:1200]
|
||||
}
|
||||
|
||||
def _extract_title(self, soup):
|
||||
"""提取网站标题"""
|
||||
# 优先使用 og:title
|
||||
@@ -80,89 +318,83 @@ class WebsiteFetcher:
|
||||
if meta_desc and meta_desc.get('content'):
|
||||
return meta_desc['content'].strip()
|
||||
|
||||
# 使用 meta keywords 作为fallback
|
||||
meta_keywords = soup.find('meta', attrs={'name': 'keywords'})
|
||||
if meta_keywords and meta_keywords.get('content'):
|
||||
return meta_keywords['content'].strip()
|
||||
|
||||
return ''
|
||||
|
||||
def _extract_logo(self, soup, base_url):
|
||||
"""提取网站Logo"""
|
||||
logo_url = None
|
||||
"""提取网站 Logo URL"""
|
||||
# 优先查找 favicon
|
||||
favicon = soup.find('link', rel='icon') or soup.find('link', rel='shortcut icon')
|
||||
if favicon and favicon.get('href'):
|
||||
return urljoin(base_url, favicon['href'])
|
||||
|
||||
# 1. 尝试 og:image
|
||||
og_image = soup.find('meta', property='og:image')
|
||||
if og_image and og_image.get('content'):
|
||||
logo_url = og_image['content']
|
||||
# 查找 apple-touch-icon
|
||||
apple_icon = soup.find('link', rel='apple-touch-icon')
|
||||
if apple_icon and apple_icon.get('href'):
|
||||
return urljoin(base_url, apple_icon['href'])
|
||||
|
||||
# 2. 尝试 link rel="icon" 或 "shortcut icon"
|
||||
if not logo_url:
|
||||
icon_link = soup.find('link', rel=lambda x: x and ('icon' in x.lower() if isinstance(x, str) else 'icon' in ' '.join(x).lower()))
|
||||
if icon_link and icon_link.get('href'):
|
||||
logo_url = icon_link['href']
|
||||
# 使用 /favicon.ico
|
||||
parsed = urlparse(base_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
|
||||
|
||||
# 3. 尝试 apple-touch-icon
|
||||
if not logo_url:
|
||||
apple_icon = soup.find('link', rel='apple-touch-icon')
|
||||
if apple_icon and apple_icon.get('href'):
|
||||
logo_url = apple_icon['href']
|
||||
|
||||
# 4. 默认使用 /favicon.ico
|
||||
if not logo_url:
|
||||
logo_url = '/favicon.ico'
|
||||
|
||||
# 转换为绝对URL
|
||||
if logo_url:
|
||||
logo_url = urljoin(base_url, logo_url)
|
||||
|
||||
return logo_url
|
||||
|
||||
def download_logo(self, logo_url, save_dir='static/uploads'):
|
||||
def download_logo(self, logo_url, save_dir='static/logos'):
|
||||
"""
|
||||
下载并保存Logo
|
||||
下载 Logo 到本地
|
||||
|
||||
Args:
|
||||
logo_url: Logo的URL
|
||||
logo_url: Logo URL
|
||||
save_dir: 保存目录
|
||||
|
||||
Returns:
|
||||
str: 保存后的相对路径,失败返回None
|
||||
str: 保存后的文件路径,失败返回 None
|
||||
"""
|
||||
if not logo_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 创建保存目录
|
||||
# 确保目录存在
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
# 下载图片
|
||||
response = requests.get(logo_url, headers=self.headers, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
response = requests.get(logo_url, headers=self.headers, timeout=10, stream=True)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
# 检查是否是图片
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if not content_type.startswith('image/'):
|
||||
content_type = response.headers.get('Content-Type', '').lower()
|
||||
if 'image' not in content_type:
|
||||
return None
|
||||
|
||||
# 生成文件名
|
||||
parsed_url = urlparse(logo_url)
|
||||
ext = os.path.splitext(parsed_url.path)[1]
|
||||
if not ext or len(ext) > 5:
|
||||
ext = '.png' # 默认扩展名
|
||||
import time
|
||||
import hashlib
|
||||
ext = '.png'
|
||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||
ext = '.jpg'
|
||||
elif 'gif' in content_type:
|
||||
ext = '.gif'
|
||||
elif 'svg' in content_type:
|
||||
ext = '.svg'
|
||||
elif 'ico' in content_type:
|
||||
ext = '.ico'
|
||||
|
||||
# 使用域名作为文件名
|
||||
domain = parsed_url.netloc.replace(':', '_').replace('.', '_')
|
||||
filename = f"logo_{domain}{ext}"
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
hash_name = hashlib.md5(f"{logo_url}{timestamp}".encode()).hexdigest()[:16]
|
||||
filename = f"logo_{hash_name}{ext}"
|
||||
filepath = os.path.join(save_dir, filename)
|
||||
|
||||
# 保存图片
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
for chunk in response.iter_content(8192):
|
||||
f.write(chunk)
|
||||
|
||||
# 返回相对路径(用于数据库存储)
|
||||
return f'/{filepath.replace(os.sep, "/")}'
|
||||
# 验证图片是否有效
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
img.verify()
|
||||
return filepath
|
||||
except:
|
||||
# 图片无效,删除文件
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"下载Logo失败: {str(e)}")
|
||||
return None
|
||||
print(f"下载 Logo 失败: {str(e)}")
|
||||
return None
|
||||
Reference in New Issue
Block a user